From c3c6f55a431f5cb4e2cb1e9df5cde0ed15a5a2d4 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Tue, 8 Sep 2026 22:36:57 -0400 Subject: [PATCH] feat: redesign photo and video post review --- design-qa.md | 46 ++ .../templates/image_review_page_template.dart | 643 +++++------------- .../templates/post_review_page_template.dart | 243 +++++++ .../templates/video_review_page_template.dart | 390 +++-------- lib/src/core/l10n/app_localizations.dart | 48 ++ lib/src/core/l10n/app_localizations_en.dart | 29 + lib/src/core/l10n/intl_en.arb | 17 + .../posting/ui/pages/image_review_page.dart | 110 ++- .../posting/ui/pages/video_review_page.dart | 41 +- .../image_review_page_template_test.dart | 300 ++++++-- .../video_review_page_template_test.dart | 162 +++++ widgetbook/lib/templates/page_templates.dart | 6 - 12 files changed, 1083 insertions(+), 952 deletions(-) create mode 100644 design-qa.md create mode 100644 lib/src/core/design_system/templates/post_review_page_template.dart create mode 100644 test/src/core/design_system/templates/video_review_page_template_test.dart diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..715485ca --- /dev/null +++ b/design-qa.md @@ -0,0 +1,46 @@ +# Review page redesign QA + +final result: passed + +## Target and evidence + +- Approved target: `/Users/knotbin/.codex/generated_images/01a07cf9-24dd-7003-bfec-31665a5a26c8/exec-93745e48-91e2-4acc-9fd3-a709256e85c4.png`. +- Photo implementation: `/Users/knotbin/.codex/visualizations/2026/09/07/01a07cf9-24dd-7003-bfec-31665a5a26c8/photo-review-implemented.png`. +- App-content comparison crop: same directory, `photo-review-content.png`. +- Keyboard evidence: same directory, `photo-review-keyboard.png`. +- Video template renders: same directory, `video-review-dark.png` and `video-review-light.png`. + +The selected concept is photo review with two portrait tiles, an add tile, direct tap-to-edit, caption, sound, crosspost, and a bottom Post action. There is no selection state or alt-text action. Video adopts the same form and footer with one centered preview and preparation status. The subsequent video refinement increases portrait preview height to 38% of the viewport (220–340 pixels), centers it horizontally, and lets landscape previews use their natural height. The video captures were refreshed after this change; all five video layout tests and focused analysis passed. + +## Comparison + +The approved mock is 853×1844 pixels (approximately 390×844 logical pixels). The live iPhone Air simulator window is 469×999 pixels, including device chrome. The 415×820 app-content crop excludes the status bar and bezel. Both the target and crop were opened together for the comparison. Video template captures are 390×844 at 1×, rendered using the app's actual theme and SN Pro font. + +Photo content: two real simulator-library images, caption “A little waterfall detour.”, no sound, crosspost off. The second real image differs from the generated concept's valley image. Device safe areas and stock app typography explain minor proportional differences; this is a responsive implementation, not a raster reproduction. Video captures use a static waterfall fixture to inspect layout, not a live upload or playback session. + +- Typography: existing SN Pro styles retained, caption and settings readable, one clear primary action. +- Spacing: single horizontal strip, 20-pixel page margins, comfortable caption area, restrained setting rows, persistent footer. Content scrolls when keyboard or large text reduces available height. +- Colors: existing light/dark tokens; pink Post action and neutral secondary controls. Video progress uses pink with a neutral track. +- Images: real photo tiles, rounded corners, no duplicate hero image or selection outline. Full photo editing opens from the tapped tile. Video preview preserves its supplied aspect ratio in a bounded region. +- Copy: all new user-facing labels are localized. Add-photo tooltip, per-photo accessible labels, and conditional crosspost warning are retained. + +Full-view captures made text, image treatment, and controls readable; separate region crops were unnecessary. + +## Iteration history + +1. Initial simulator comparison found the add tile used a circled plus. Replaced it with the existing plain add symbol; the final photo capture shows the correction. +2. Initial video render exposed Flutter's unrelated progress-track accent. Set the track to the app's neutral surface color; final light/dark video captures verify the correction. +3. A temporary image capture harness initially stalled on image precaching, then produced an unloaded frame. Those captures were rejected. The final harness decodes the fixture before rendering; final captures show the image. The temporary harness was removed from the repository. + +No actionable P0/P1/P2 visual differences remain. The solid add-tile border intentionally reuses the existing component treatment instead of introducing a custom dashed painter. + +The subsequent requested refinement removes the tap-to-edit helper and its spacing; the photo tiles remain directly tappable. The saved photo captures precede that text-only refinement. + +## Validation and limits + +- Live simulator: add photo, open second photo in editor, cancel back to review, type caption, show/dismiss software keyboard. Post remains above the keyboard. +- Widget regressions: 11 tests cover editing/removal indices, sound, empty-post prevention, add limit, posting locks, 320×568 with safe areas/keyboard/text scale 2, video aspect ratios, and upload retry/processing states. +- Full Flutter suite: 789 tests passed. +- App, Widgetbook and test-source analysis passed; touched-source formatting and diff checks passed. +- Read-only production correctness review found no actionable defects. +- Native video playback and actual publishing were not exercised during this redesign. Existing playback and upload tests pass; controller ownership and teardown were preserved. diff --git a/lib/src/core/design_system/templates/image_review_page_template.dart b/lib/src/core/design_system/templates/image_review_page_template.dart index 788cec74..5654e2ec 100644 --- a/lib/src/core/design_system/templates/image_review_page_template.dart +++ b/lib/src/core/design_system/templates/image_review_page_template.dart @@ -1,36 +1,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; -import 'package:spark/src/core/design_system/components/atoms/toggles/app_toggle.dart'; -import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/design_system/templates/post_review_page_template.dart'; import 'package:spark/src/core/design_system/tokens/shapes.dart'; -import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; -import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart'; -/// Design-only template for the Image Review flow. -/// -/// The template composes page chrome & common sections with the design system. -/// Content-heavy areas (image pager and description) are provided as slots. class ImageReviewPageTemplate extends StatelessWidget { const ImageReviewPageTemplate({ required this.title, required this.onBack, required this.imagePaths, - required this.currentPage, - required this.onPageChanged, required this.onTapEditImage, - required this.onAltEdit, required this.onRemoveImage, required this.showAddMore, required this.canAddMore, - required this.imagesCount, - required this.maxImages, required this.onAddMore, required this.descriptionMaxChars, required this.postLabel, @@ -45,7 +30,8 @@ class ImageReviewPageTemplate extends StatelessWidget { this.onRemoveSound, this.descriptionController, this.mentionController, - this.onMentionsChanged, + this.showCaption = true, + this.showCrossPost = true, this.showCrossPostWarning = false, this.backgroundColor, this.isOverLimit = false, @@ -54,20 +40,16 @@ class ImageReviewPageTemplate extends StatelessWidget { final String title; final VoidCallback onBack; final List imagePaths; - final int currentPage; - final ValueChanged onPageChanged; final ValueChanged onTapEditImage; - final ValueChanged onAltEdit; final ValueChanged onRemoveImage; final bool showAddMore; final bool canAddMore; - final int imagesCount; - final int maxImages; final VoidCallback onAddMore; final TextEditingController? descriptionController; final MentionController? mentionController; - final ValueChanged>? onMentionsChanged; final int descriptionMaxChars; + final bool showCaption; + final bool showCrossPost; final bool crossPostValue; final ValueChanged onCrossPostChanged; final bool showCrossPostWarning; @@ -83,295 +65,173 @@ class ImageReviewPageTemplate extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Scaffold( - backgroundColor: backgroundColor ?? colorScheme.surface, - appBar: AppBar( - backgroundColor: backgroundColor ?? colorScheme.surface, - elevation: 0, - leading: AppLeadingButton( - color: theme.textTheme.titleLarge?.color, - tooltip: l10n.buttonBack, - ), - title: Text(title), - centerTitle: false, - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ImagePager( - imagePaths: imagePaths, - onTapEditImage: onTapEditImage, - onAltEdit: onAltEdit, - onRemoveImage: onRemoveImage, - currentPage: currentPage, - onPageChanged: onPageChanged, - ), - if (showAddMore) ...[ - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: AppButton( - label: canAddMore - ? 'Add More Images ($imagesCount/$maxImages)' - : 'Image Limit Reached', - onPressed: canAddMore ? onAddMore : null, - size: AppButtonSize.compact, - fullWidth: true, - ), - ), - ], - if (onAddSound != null) ...[ - const SizedBox(height: 20), - _SoundSection( - title: selectedSoundTitle, - subtitle: selectedSoundSubtitle, - onAddSound: onAddSound!, - onRemoveSound: onRemoveSound, - ), - ], - const SizedBox(height: 20), - _DescriptionSection( - controller: descriptionController, - mentionController: mentionController, - onMentionsChanged: onMentionsChanged, - maxChars: descriptionMaxChars, - ), - const SizedBox(height: 20), - _CrossPostSection( - value: crossPostValue, - onChanged: onCrossPostChanged, - showWarning: showCrossPostWarning, - ), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: isPosting - ? Container( - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.primary500.withAlpha(128), - borderRadius: BorderRadius.circular(8), - ), - child: const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.greyWhite, - ), - ), - ) - : AppButton( - label: postLabel, - onPressed: isOverLimit ? null : onPost, - size: AppButtonSize.compact, - fullWidth: true, - ), - ), - ), - ], - ), + return PostReviewPageTemplate( + title: title, + onBack: onBack, + backgroundColor: backgroundColor, + media: _PhotoStrip( + imagePaths: imagePaths, + onTapEditImage: onTapEditImage, + onRemoveImage: onRemoveImage, + showAddMore: showAddMore, + canAddMore: canAddMore, + onAddMore: onAddMore, + enabled: !isPosting, ), + caption: showCaption + ? PostReviewCaption( + controller: descriptionController, + mentionController: mentionController, + maxChars: descriptionMaxChars, + enabled: !isPosting, + ) + : const SizedBox.shrink(), + options: [ + if (onAddSound != null) + _SoundSection( + title: selectedSoundTitle, + subtitle: selectedSoundSubtitle, + onAddSound: isPosting ? null : onAddSound, + onRemoveSound: isPosting ? null : onRemoveSound, + ), + if (onAddSound != null && showCrossPost) const Divider(height: 24), + if (showCrossPost) + PostReviewCrossPost( + value: crossPostValue, + onChanged: onCrossPostChanged, + showWarning: showCrossPostWarning, + ), + ], + postLabel: postLabel, + onPost: isPosting || isOverLimit || imagePaths.isEmpty ? null : onPost, + isPosting: isPosting, ); } } -class _ImagePager extends StatelessWidget { - const _ImagePager({ +class _PhotoStrip extends StatelessWidget { + const _PhotoStrip({ required this.imagePaths, - required this.currentPage, - required this.onPageChanged, required this.onTapEditImage, - required this.onAltEdit, required this.onRemoveImage, + required this.showAddMore, + required this.canAddMore, + required this.onAddMore, + required this.enabled, }); final List imagePaths; - final int currentPage; - final ValueChanged onPageChanged; final ValueChanged onTapEditImage; - final ValueChanged onAltEdit; final ValueChanged onRemoveImage; + final bool showAddMore; + final bool canAddMore; + final VoidCallback onAddMore; + final bool enabled; + + void _showPhotoActions(BuildContext context, int index) { + final l10n = AppLocalizations.of(context); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: ListTile( + leading: AppIcon( + AppIconData.delete, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text(l10n.buttonRemove), + onTap: () { + Navigator.of(sheetContext).pop(); + onRemoveImage(index); + }, + ), + ), + ), + ); + } @override Widget build(BuildContext context) { - if (imagePaths.isEmpty) return const SizedBox.shrink(); - final radius = BorderRadiusGeometry.circular(AppShapes.squircleRadius); - final side = BorderSide( - width: AppShapes.squircleBorderWidth, - color: Colors.white.withAlpha(AppShapes.squircleBorderAlpha), - ); - final ShapeBorder shape = RoundedSuperellipseBorder( - side: side, - borderRadius: radius, - ); + final l10n = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + final radius = BorderRadius.circular(AppShapes.squircleRadius); - return AspectRatio( - aspectRatio: 1, - child: DecoratedBox( - decoration: ShapeDecoration(shape: shape), - child: Material( - color: Colors.transparent, - shape: RoundedSuperellipseBorder(borderRadius: radius), - clipBehavior: Clip.antiAlias, - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - PageView.builder( - itemCount: imagePaths.length, - onPageChanged: onPageChanged, - itemBuilder: (context, index) { - final path = imagePaths[index]; - return GestureDetector( - onTap: () => onTapEditImage(index), - child: Stack( - children: [ - // Image content fills the clipped shape - Positioned.fill( - child: Image( - image: FileImage(File(path)), - fit: BoxFit.cover, - ), - ), - Positioned( - bottom: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black.withAlpha(150), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: Colors.white.withAlpha(38), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const AppIcon( - AppIconData.edit, - color: AppColors.greyWhite, - size: 16, - ), - const SizedBox(width: 6), - Text( - 'Tap to edit', - style: AppTypography.textSmallBold.copyWith( - color: AppColors.greyWhite, - ), - ), - ], - ), - ), - ), - Positioned( - bottom: 8, - right: 8, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // ALT text editor chip - GestureDetector( - onTap: () => onAltEdit(index), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black.withAlpha(100), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withAlpha(38), - ), - ), - child: Row( - children: [ - Text( - 'ALT', - style: AppTypography.textSmallBold - .copyWith( - color: AppColors.greyWhite, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - // Remove image - GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.black.withAlpha(100), - border: Border.all( - color: Colors.white.withAlpha(38), - ), - ), - child: AppIcons.cancel( - size: 14, - color: AppColors.greyWhite, - ), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ), - if (imagePaths.length > 1) - Positioned( - bottom: 10, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + return SizedBox( + height: 168, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imagePaths.length + (showAddMore ? 1 : 0), + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (context, index) { + if (index == imagePaths.length) { + return Semantics( + label: canAddMore + ? l10n.reviewAddPhotos + : l10n.reviewImageLimitReached, + button: true, + enabled: enabled && canAddMore, + child: Tooltip( + message: canAddMore + ? l10n.reviewAddPhotos + : l10n.reviewImageLimitReached, + child: SizedBox( + width: 112, + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: radius, + side: BorderSide(color: colorScheme.outlineVariant), ), - decoration: BoxDecoration( - color: Colors.black.withAlpha(100), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.white.withAlpha(38)), - ), - child: Text( - '${currentPage + 1} / ${imagePaths.length}', - style: AppTypography.textExtraSmallMedium.copyWith( - color: AppColors.greyWhite, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled && canAddMore ? onAddMore : null, + child: Center( + child: AppIcon( + AppIconData.add, + size: 28, + color: enabled && canAddMore + ? colorScheme.primary + : colorScheme.onSurface.withValues(alpha: 0.38), + ), ), ), ), ), - ], - ), - ), + ), + ); + } + + return Semantics( + label: l10n.reviewEditPhoto(index + 1), + button: true, + enabled: enabled, + onLongPressHint: showAddMore || imagePaths.length > 1 + ? l10n.reviewRemovePhoto(index + 1) + : null, + child: SizedBox( + width: 112, + child: Material( + color: colorScheme.surfaceContainerHighest, + borderRadius: radius, + clipBehavior: Clip.antiAlias, + child: Ink.image( + image: FileImage(File(imagePaths[index])), + fit: BoxFit.cover, + child: InkWell( + onTap: enabled ? () => onTapEditImage(index) : null, + onLongPress: + enabled && (showAddMore || imagePaths.length > 1) + ? () => _showPhotoActions(context, index) + : null, + ), + ), + ), + ), + ); + }, ), ); } @@ -387,197 +247,34 @@ class _SoundSection extends StatelessWidget { final String? title; final String? subtitle; - final VoidCallback onAddSound; + final VoidCallback? onAddSound; final VoidCallback? onRemoveSound; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; final hasSound = title != null && title!.trim().isNotEmpty; - if (!hasSound) { - return SizedBox( - width: double.infinity, - child: AppButton( - label: l10n.buttonAddSound, - onPressed: onAddSound, - size: AppButtonSize.compact, - fullWidth: true, - ), - ); - } - - return Material( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.6)), + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const AppIcon(AppIconData.music), + title: Text( + hasSound ? title! : l10n.buttonAddSound, + maxLines: hasSound ? 1 : null, + overflow: hasSound ? TextOverflow.ellipsis : null, ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - leading: const AppIcon(AppIconData.music), - title: Text( - title!, - style: AppTypography.textMediumBold.copyWith( - color: colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: subtitle == null || subtitle!.isEmpty - ? null - : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: IconButton( - tooltip: l10n.buttonRemove, - icon: const AppIcon(AppIconData.cancel), - onPressed: onRemoveSound, - ), - onTap: onAddSound, - ), - ); - } -} - -class _DescriptionSection extends StatelessWidget { - const _DescriptionSection({ - this.controller, - this.mentionController, - this.onMentionsChanged, - required this.maxChars, - }); - - final TextEditingController? controller; - final MentionController? mentionController; - final ValueChanged>? onMentionsChanged; - final int maxChars; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final textController = mentionController?.textController ?? controller; - final count = textController?.text.runes.length ?? 0; - final showCounter = count >= (maxChars * 0.8); - final isNearLimit = count >= maxChars * 0.9; - final isOverLimit = count > maxChars; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (mentionController != null) - MentionInputField( - controller: mentionController!, - onMentionsChanged: onMentionsChanged ?? (_) {}, - hintText: l10n.hintAddDescription, - ) - else if (controller != null) - InputField.search( - controller: controller!, - hintText: l10n.hintAddDescription, - maxLines: 5, - minLines: 1, - ), - if (showCounter) ...[ - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Text( - '$count/$maxChars', - style: AppTypography.textSmallMedium.copyWith( - color: isOverLimit - ? AppColors.red300 - : isNearLimit - ? AppColors.rajah500 - : Colors.white.withAlpha(160), - ), - ), - ), - ], - ], - ); - } -} - -class _CrossPostSection extends StatelessWidget { - const _CrossPostSection({ - required this.value, - required this.onChanged, - required this.showWarning, - }); - - final bool value; - final ValueChanged onChanged; - final bool showWarning; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - final tileColor = colorScheme.surfaceContainerHighest.withValues( - alpha: 0.5, - ); - final borderColor = colorScheme.outline.withValues(alpha: 0.6); - final titleColor = colorScheme.onSurface; - - return Column( - children: [ - Material( - color: tileColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: borderColor), - ), - child: MergeSemantics( - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 2, - ), - title: Text( - 'Post to Bluesky', - style: AppTypography.textMediumBold.copyWith(color: titleColor), - ), - trailing: AppToggle(value: value, onChanged: onChanged), - onTap: () => onChanged(!value), - ), - ), - ), - if (showWarning) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.rajah500.withAlpha(25), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.rajah500.withAlpha(64)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const AppIcon( - AppIconData.warning, - color: AppColors.rajah500, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Bluesky supports a maximum of 4 images. ' - 'Your crosspost will include the first 4 and link to the ' - 'full Spark post.', - style: AppTypography.textSmallMedium.copyWith( - color: AppColors.rajah500, - ), - ), - ), - ], - ), - ), - ], - ], + subtitle: hasSound && subtitle != null && subtitle!.isNotEmpty + ? Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis) + : null, + trailing: hasSound + ? IconButton( + tooltip: l10n.buttonRemove, + icon: const AppIcon(AppIconData.cancel), + onPressed: onRemoveSound, + ) + : const AppIcon(AppIconData.chevronRight, size: 20), + enabled: onAddSound != null, + onTap: onAddSound, ); } } diff --git a/lib/src/core/design_system/templates/post_review_page_template.dart b/lib/src/core/design_system/templates/post_review_page_template.dart new file mode 100644 index 00000000..23eafe52 --- /dev/null +++ b/lib/src/core/design_system/templates/post_review_page_template.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; +import 'package:spark/src/core/design_system/components/atoms/toggles/app_toggle.dart'; +import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/features/posting/models/mention_controller.dart'; +import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart'; + +class PostReviewPageTemplate extends StatelessWidget { + const PostReviewPageTemplate({ + required this.title, + required this.onBack, + required this.media, + required this.caption, + required this.options, + required this.postLabel, + required this.onPost, + required this.isPosting, + this.backgroundColor, + this.status, + super.key, + }); + + final String title; + final VoidCallback onBack; + final Widget media; + final Widget caption; + final List options; + final String postLabel; + final VoidCallback? onPost; + final bool isPosting; + final Color? backgroundColor; + final Widget? status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + backgroundColor: backgroundColor ?? theme.colorScheme.surface, + appBar: AppBar( + backgroundColor: backgroundColor ?? theme.colorScheme.surface, + scrolledUnderElevation: 0, + leading: AppLeadingButton( + onPressed: onBack, + tooltip: AppLocalizations.of(context).buttonBack, + color: theme.colorScheme.onSurface, + ), + title: Text(title), + centerTitle: false, + ), + body: SafeArea( + top: false, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), + child: AbsorbPointer( + absorbing: isPosting, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + media, + const SizedBox(height: 28), + caption, + const SizedBox(height: 20), + ...options, + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (status != null) ...[ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * 0.3, + ), + child: SingleChildScrollView(child: status), + ), + const SizedBox(height: 12), + ], + AppButton( + label: postLabel, + onPressed: isPosting ? null : onPost, + fullWidth: true, + leading: isPosting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : null, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class PostReviewCaption extends StatelessWidget { + const PostReviewCaption({ + required this.maxChars, + this.controller, + this.mentionController, + this.enabled = true, + super.key, + }); + + final int maxChars; + final TextEditingController? controller; + final MentionController? mentionController; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + final textController = mentionController?.textController ?? controller; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.reviewCaption, + style: AppTypography.textMediumMedium.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Theme( + data: theme.copyWith( + inputDecorationTheme: theme.inputDecorationTheme.copyWith( + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + child: mentionController != null + ? MentionInputField( + controller: mentionController!, + onMentionsChanged: (_) {}, + hintText: l10n.hintAddDescription, + maxChars: maxChars, + minLines: 5, + maxLines: 8, + enabled: enabled, + ) + : InputField.search( + controller: controller, + hintText: l10n.hintAddDescription, + minLines: 5, + maxLines: 8, + enabled: enabled, + ), + ), + if (textController != null) + ValueListenableBuilder( + valueListenable: textController, + builder: (context, value, _) { + final count = value.text.runes.length; + if (count < maxChars * 0.8) return const SizedBox.shrink(); + return Align( + alignment: AlignmentDirectional.centerEnd, + child: Text( + '$count/$maxChars', + style: AppTypography.textSmallMedium.copyWith( + color: count > maxChars + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + }, + ), + ], + ); + } +} + +class PostReviewCrossPost extends StatelessWidget { + const PostReviewCrossPost({ + required this.value, + required this.onChanged, + this.showWarning = false, + super.key, + }); + + final bool value; + final ValueChanged onChanged; + final bool showWarning; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MergeSemantics( + child: ListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.reviewAlsoPostToBluesky), + trailing: AppToggle(value: value, onChanged: onChanged), + onTap: () => onChanged(!value), + ), + ), + if (showWarning) + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: Text( + l10n.reviewCrosspostImageWarning, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/core/design_system/templates/video_review_page_template.dart b/lib/src/core/design_system/templates/video_review_page_template.dart index daa544e6..68817a55 100644 --- a/lib/src/core/design_system/templates/video_review_page_template.dart +++ b/lib/src/core/design_system/templates/video_review_page_template.dart @@ -1,25 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; -import 'package:spark/src/core/design_system/components/atoms/toggles/app_toggle.dart'; -import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; -import 'package:spark/src/core/design_system/tokens/shapes.dart'; -import 'package:spark/src/core/design_system/tokens/typography.dart'; +import 'package:spark/src/core/design_system/templates/post_review_page_template.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; -import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart'; -/// Design-only template for the Video Review flow. -/// -/// Mirrors ImageReviewPageTemplate styling and sections while accepting a -/// provided [videoPreview] widget and [aspectRatio] to render the media. class VideoReviewPageTemplate extends StatelessWidget { const VideoReviewPageTemplate({ required this.title, required this.onBack, required this.videoPreview, - required this.onAltEdit, required this.descriptionMaxChars, required this.postLabel, required this.onPost, @@ -28,7 +16,7 @@ class VideoReviewPageTemplate extends StatelessWidget { required this.onCrossPostChanged, this.descriptionController, this.mentionController, - this.onMentionsChanged, + this.showCaption = true, this.showCrossPost = true, this.aspectRatio = 1.0, this.backgroundColor, @@ -42,16 +30,14 @@ class VideoReviewPageTemplate extends StatelessWidget { }); final String title; - final VoidCallback - onBack; // Kept for API symmetry; AppLeadingButton handles back internally. + final VoidCallback onBack; final Widget videoPreview; - final VoidCallback onAltEdit; final TextEditingController? descriptionController; final MentionController? mentionController; - final ValueChanged>? onMentionsChanged; final int descriptionMaxChars; final bool crossPostValue; final ValueChanged onCrossPostChanged; + final bool showCaption; final bool showCrossPost; final String postLabel; final VoidCallback? onPost; @@ -67,340 +53,122 @@ class VideoReviewPageTemplate extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Scaffold( - backgroundColor: backgroundColor ?? colorScheme.surface, - appBar: AppBar( - backgroundColor: backgroundColor ?? colorScheme.surface, - elevation: 0, - leading: AppLeadingButton( - color: theme.textTheme.titleLarge?.color, - tooltip: l10n.buttonBack, - onPressed: onBack, - ), - title: Text(title), - centerTitle: false, - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _VideoPreview( - aspectRatio: aspectRatio <= 0 ? 1 : aspectRatio, - onAltEdit: onAltEdit, - child: videoPreview, - ), - if (uploadStatusLabel != null) ...[ - const SizedBox(height: 16), - _UploadStatusSection( - progress: uploadProgress ?? 0, - label: uploadStatusLabel!, - isIndeterminate: uploadIndeterminate, - hasError: hasUploadError, - onRetry: onUploadRetry, - ), - ], - const SizedBox(height: 20), - _DescriptionSection( - controller: descriptionController, - mentionController: mentionController, - onMentionsChanged: onMentionsChanged, - maxChars: descriptionMaxChars, - ), - if (showCrossPost) ...[ - const SizedBox(height: 20), - _CrossPostSection( - value: crossPostValue, - onChanged: onCrossPostChanged, - ), - ], - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: isPosting - ? Container( - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.primary500.withAlpha(128), - borderRadius: BorderRadius.circular(8), - ), - child: const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.greyWhite, - ), - ), - ) - : AppButton( - label: postLabel, - onPressed: isOverLimit ? null : onPost, - size: AppButtonSize.compact, - fullWidth: true, - ), - ), + final previewHeight = (MediaQuery.sizeOf(context).height * 0.38).clamp( + 220.0, + 340.0, + ); + return PostReviewPageTemplate( + title: title, + onBack: onBack, + backgroundColor: backgroundColor, + media: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: previewHeight), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AspectRatio( + aspectRatio: aspectRatio.isFinite && aspectRatio > 0 + ? aspectRatio + : 1, + child: ColoredBox(color: Colors.black, child: videoPreview), ), - ], + ), ), ), + caption: showCaption + ? PostReviewCaption( + controller: descriptionController, + mentionController: mentionController, + maxChars: descriptionMaxChars, + enabled: !isPosting, + ) + : const SizedBox.shrink(), + options: [ + if (showCrossPost) + PostReviewCrossPost( + value: crossPostValue, + onChanged: onCrossPostChanged, + ), + ], + status: uploadStatusLabel == null + ? null + : _UploadStatus( + label: uploadStatusLabel!, + progress: uploadProgress, + isIndeterminate: uploadIndeterminate, + hasError: hasUploadError, + onRetry: isPosting ? null : onUploadRetry, + ), + postLabel: postLabel, + isPosting: isPosting, + onPost: isOverLimit ? null : onPost, ); } } -class _UploadStatusSection extends StatelessWidget { - const _UploadStatusSection({ - required this.progress, +class _UploadStatus extends StatelessWidget { + const _UploadStatus({ required this.label, + required this.progress, required this.isIndeterminate, required this.hasError, - this.onRetry, + required this.onRetry, }); - final double progress; final String label; + final double? progress; final bool isIndeterminate; final bool hasError; final VoidCallback? onRetry; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final clampedProgress = progress.clamp(0, 1).toDouble(); - final percent = (clampedProgress * 100).round(); - final accent = hasError ? AppColors.red300 : AppColors.primary500; - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), - ), + final colors = Theme.of(context).colorScheme; + final value = progress?.clamp(0.0, 1.0); + return Semantics( + liveRegion: true, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( label, - style: AppTypography.textMediumBold.copyWith( - color: colorScheme.onSurface, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: hasError ? colors.error : colors.onSurfaceVariant, ), ), ), - if (!isIndeterminate) - Text( - '$percent%', - style: AppTypography.textSmallBold.copyWith(color: accent), + if (!hasError && !isIndeterminate && value != null && value < 1) + Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: Text('${(value * 100).round()}%'), ), ], ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: isIndeterminate ? null : clampedProgress, - minHeight: 6, - backgroundColor: colorScheme.outline.withValues(alpha: 0.18), - valueColor: AlwaysStoppedAnimation(accent), - ), - ), - if (hasError && onRetry != null) ...[ + if (!hasError && + (isIndeterminate || (value != null && value < 1))) ...[ const SizedBox(height: 8), + LinearProgressIndicator( + value: isIndeterminate ? null : value, + minHeight: 3, + borderRadius: BorderRadius.circular(2), + color: colors.primary, + backgroundColor: colors.surfaceContainerHighest, + ), + ], + if (hasError && onRetry != null) Align( - alignment: Alignment.centerRight, + alignment: AlignmentDirectional.centerEnd, child: TextButton( onPressed: onRetry, child: Text(AppLocalizations.of(context).buttonTryAgain), ), ), - ], ], ), ); } } - -class _VideoPreview extends StatelessWidget { - const _VideoPreview({ - required this.aspectRatio, - required this.child, - required this.onAltEdit, - }); - - final double aspectRatio; - final Widget child; - final VoidCallback onAltEdit; - - @override - Widget build(BuildContext context) { - final radius = BorderRadiusGeometry.circular(AppShapes.squircleRadius); - final side = BorderSide( - width: AppShapes.squircleBorderWidth, - color: Colors.white.withAlpha(AppShapes.squircleBorderAlpha), - ); - final ShapeBorder shape = RoundedSuperellipseBorder( - side: side, - borderRadius: radius, - ); - - return AspectRatio( - aspectRatio: aspectRatio, - child: DecoratedBox( - decoration: ShapeDecoration(shape: shape), - child: Material( - color: Colors.transparent, - shape: RoundedSuperellipseBorder(borderRadius: radius), - clipBehavior: Clip.antiAlias, - child: Stack( - children: [ - Positioned.fill(child: child), - Positioned( - bottom: 8, - right: 8, - child: GestureDetector( - onTap: onAltEdit, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black.withAlpha(100), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withAlpha(38)), - ), - child: Text( - 'ALT', - style: AppTypography.textSmallBold.copyWith( - color: AppColors.greyWhite, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _DescriptionSection extends StatelessWidget { - const _DescriptionSection({ - this.controller, - this.mentionController, - this.onMentionsChanged, - required this.maxChars, - }); - - final TextEditingController? controller; - final MentionController? mentionController; - final ValueChanged>? onMentionsChanged; - final int maxChars; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final textController = mentionController?.textController ?? controller; - final count = textController?.text.runes.length ?? 0; - final showCounter = count >= (maxChars * 0.8); - final isNearLimit = count >= maxChars * 0.9; - final isOverLimit = count > maxChars; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (mentionController != null) - MentionInputField( - controller: mentionController!, - onMentionsChanged: onMentionsChanged ?? (_) {}, - hintText: l10n.hintAddDescription, - ) - else if (controller != null) - InputField.search( - controller: controller!, - hintText: l10n.hintAddDescription, - maxLines: 5, - minLines: 1, - ), - if (showCounter) ...[ - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Text( - '$count/$maxChars', - style: AppTypography.textSmallMedium.copyWith( - color: isOverLimit - ? AppColors.red300 - : isNearLimit - ? AppColors.rajah500 - : Colors.white.withAlpha(160), - ), - ), - ), - ], - ], - ); - } -} - -class _CrossPostSection extends StatelessWidget { - const _CrossPostSection({required this.value, required this.onChanged}); - - final bool value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Material( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.6)), - ), - clipBehavior: Clip.antiAlias, - child: MergeSemantics( - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 2, - ), - title: Text( - 'Post to Bluesky', - style: AppTypography.textMediumBold.copyWith( - color: colorScheme.onSurface, - ), - ), - trailing: AppToggle(value: value, onChanged: onChanged), - onTap: () => onChanged(!value), - ), - ), - ); - } -} diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index 8fa389d7..d5660a90 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -94,6 +94,54 @@ abstract class AppLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; + /// No description provided for @pageTitleReviewPost. + /// + /// In en, this message translates to: + /// **'Review post'** + String get pageTitleReviewPost; + + /// No description provided for @reviewCaption. + /// + /// In en, this message translates to: + /// **'Caption'** + String get reviewCaption; + + /// Accessible action label for editing a photo in review + /// + /// In en, this message translates to: + /// **'Edit photo {index}'** + String reviewEditPhoto(int index); + + /// Accessible action label for removing a photo in review + /// + /// In en, this message translates to: + /// **'Remove photo {index}'** + String reviewRemovePhoto(int index); + + /// No description provided for @reviewAddPhotos. + /// + /// In en, this message translates to: + /// **'Add photos'** + String get reviewAddPhotos; + + /// No description provided for @reviewImageLimitReached. + /// + /// In en, this message translates to: + /// **'Photo limit reached'** + String get reviewImageLimitReached; + + /// No description provided for @reviewAlsoPostToBluesky. + /// + /// In en, this message translates to: + /// **'Also post to Bluesky'** + String get reviewAlsoPostToBluesky; + + /// No description provided for @reviewCrosspostImageWarning. + /// + /// In en, this message translates to: + /// **'Bluesky supports up to 4 images. Your crosspost will include the first 4 and link to the full Spark post.'** + String get reviewCrosspostImageWarning; + /// Cancel button text /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index 32ea7759..101d515e 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -9,6 +9,35 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get pageTitleReviewPost => 'Review post'; + + @override + String get reviewCaption => 'Caption'; + + @override + String reviewEditPhoto(int index) { + return 'Edit photo $index'; + } + + @override + String reviewRemovePhoto(int index) { + return 'Remove photo $index'; + } + + @override + String get reviewAddPhotos => 'Add photos'; + + @override + String get reviewImageLimitReached => 'Photo limit reached'; + + @override + String get reviewAlsoPostToBluesky => 'Also post to Bluesky'; + + @override + String get reviewCrosspostImageWarning => + 'Bluesky supports up to 4 images. Your crosspost will include the first 4 and link to the full Spark post.'; + @override String get buttonCancel => 'Cancel'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 6ec400cf..48ae9ede 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -2,6 +2,23 @@ "@@locale": "en", "@@context": "General localization strings", + "pageTitleReviewPost": "Review post", + "reviewCaption": "Caption", + "reviewEditPhoto": "Edit photo {index}", + "@reviewEditPhoto": { + "description": "Accessible action label for editing a photo in review", + "placeholders": {"index": {"type": "int"}} + }, + "reviewRemovePhoto": "Remove photo {index}", + "@reviewRemovePhoto": { + "description": "Accessible action label for removing a photo in review", + "placeholders": {"index": {"type": "int"}} + }, + "reviewAddPhotos": "Add photos", + "reviewImageLimitReached": "Photo limit reached", + "reviewAlsoPostToBluesky": "Also post to Bluesky", + "reviewCrosspostImageWarning": "Bluesky supports up to 4 images. Your crosspost will include the first 4 and link to the full Spark post.", + "buttonCancel": "Cancel", "@buttonCancel": { "description": "Cancel button text" diff --git a/lib/src/features/posting/ui/pages/image_review_page.dart b/lib/src/features/posting/ui/pages/image_review_page.dart index c99d17bc..4c3a9087 100644 --- a/lib/src/features/posting/ui/pages/image_review_page.dart +++ b/lib/src/features/posting/ui/pages/image_review_page.dart @@ -11,7 +11,7 @@ import 'package:spark/src/core/design_system/templates/image_review_page_templat import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/core/network/atproto/atproto.dart'; import 'package:spark/src/core/routing/app_router.dart'; -import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; +import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/features/auth/providers/auth_providers.dart'; import 'package:spark/src/features/media_editor/canvas/ui/pages/post_image_editor_page.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; @@ -37,28 +37,33 @@ class ImageReviewPage extends ConsumerStatefulWidget { class _ImageReviewPageState extends ConsumerState { final MentionController _descriptionController = MentionController(); bool _isPosting = false; - int _currentPage = 0; List _imageFiles = []; static const int _maxImages = 12; final ImagePicker _picker = ImagePicker(); - final Map _altTexts = {}; bool _crosspostToBsky = false; AudioTrack? _selectedSoundTrack; late final FeedRepository _feedRepository; - Future showImageEditor(BuildContext context, XFile imageFile) async { - final newImage = await PostImageEditorPage.open(context, imageFile); - // If the user edited the image, replace the original file in the list - if (newImage != null) { + Future _editImage(int index) async { + final imageFile = _imageFiles[index]; + try { + final newImage = await PostImageEditorPage.open(context, imageFile); + if (!mounted || newImage == null) return; + final currentIndex = _imageFiles.indexOf(imageFile); + if (currentIndex < 0) return; + setState(() => _imageFiles[currentIndex] = newImage); + } catch (error, stackTrace) { + GetIt.I() + .getLogger('ImageReviewPage') + .e( + 'Failed to edit review photo', + error: error, + stackTrace: stackTrace, + ); if (!mounted) return; - setState(() { - final oldPath = _imageFiles[_currentPage].path; - final existingAlt = _altTexts.remove(oldPath); - _imageFiles[_currentPage] = newImage; - if (existingAlt != null) { - _altTexts[newImage.path] = existingAlt; - } - }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).errorGeneric)), + ); } } @@ -78,36 +83,35 @@ class _ImageReviewPageState extends ConsumerState { super.dispose(); } - Future _editAltText(XFile imageFile) async { - final path = imageFile.path; - final initialText = _altTexts[path] ?? ''; - final result = await showDialog( - context: context, - builder: (context) => AltTextEditorDialog( - imageFile: imageFile.path, - initialAltText: initialText, - ), - ); - if (result == null) return; - setState(() { - _altTexts[path] = result.trim(); - }); - } - Future _pickMoreImages() async { final remaining = _maxImages - _imageFiles.length; if (remaining <= 0) return; try { - final pickedFiles = await _picker.pickMultiImage(limit: remaining); - if (pickedFiles.isEmpty) return; + final List pickedFiles; + if (remaining == 1) { + final pickedFile = await _picker.pickImage(source: ImageSource.gallery); + pickedFiles = [?pickedFile]; + } else { + pickedFiles = await _picker.pickMultiImage(limit: remaining); + } + if (!mounted || pickedFiles.isEmpty) return; setState(() { - _imageFiles.addAll(pickedFiles); - for (final file in pickedFiles) { - _altTexts[file.path] = ''; - } + _imageFiles.addAll(pickedFiles.take(_maxImages - _imageFiles.length)); }); - } catch (_) { + } catch (error, stackTrace) { + GetIt.I() + .getLogger('ImageReviewPage') + .e( + 'Failed to pick review photos', + error: error, + stackTrace: stackTrace, + ); if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).errorUnableToAccessPhotos), + ), + ); } } @@ -127,7 +131,7 @@ class _ImageReviewPageState extends ConsumerState { } Future _uploadImagesAndPost() async { - if (_isPosting) return null; + if (_isPosting || _imageFiles.isEmpty) return null; setState(() { _isPosting = true; }); @@ -139,7 +143,7 @@ class _ImageReviewPageState extends ConsumerState { if (widget.storyMode) { final uploadedImage = await _feedRepository.uploadImages( imageFiles: _imageFiles, - altTexts: _altTexts, + altTexts: const {}, ); if (uploadedImage.isEmpty) { throw Exception('No images uploaded'); @@ -158,7 +162,7 @@ class _ImageReviewPageState extends ConsumerState { result = await _feedRepository.postImages( description, _imageFiles, - _altTexts, + const {}, crosspostToBsky: crosspostEnabled, facets: facets, soundRef: decodeSoundTrackStrongRef(_selectedSoundTrack?.id), @@ -183,36 +187,22 @@ class _ImageReviewPageState extends ConsumerState { final isOverLimit = textLength > AppConstants.postDescriptionMaxChars; return ImageReviewPageTemplate( - title: l10n.pageTitleReviewImagePost, + title: l10n.pageTitleReviewPost, onBack: () => context.router.maybePop(), imagePaths: _imageFiles.map((e) => e.path).toList(), - currentPage: _currentPage, - onPageChanged: (i) => setState(() => _currentPage = i), - onTapEditImage: (i) => showImageEditor(context, _imageFiles[i]), - onAltEdit: (i) => _editAltText(_imageFiles[i]), - onRemoveImage: (i) { - setState(() { - final removed = _imageFiles.removeAt(i); - _altTexts.remove(removed.path); - if (_currentPage >= _imageFiles.length && _currentPage > 0) { - _currentPage = _imageFiles.length - 1; - } - }); - }, + onTapEditImage: _editImage, + onRemoveImage: (index) => setState(() => _imageFiles.removeAt(index)), showAddMore: !widget.storyMode, canAddMore: canPickMore, - imagesCount: _imageFiles.length, - maxImages: _maxImages, onAddMore: _pickMoreImages, selectedSoundTitle: _selectedSoundTrack?.title, selectedSoundSubtitle: _selectedSoundTrack?.subtitle, onAddSound: widget.storyMode ? null : _selectSound, onRemoveSound: () => setState(() => _selectedSoundTrack = null), mentionController: _descriptionController, - onMentionsChanged: (mentions) { - // Mentions are automatically tracked in the controller - }, descriptionMaxChars: AppConstants.postDescriptionMaxChars, + showCaption: !widget.storyMode, + showCrossPost: !widget.storyMode, crossPostValue: _crosspostToBsky, onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), showCrossPostWarning: showCrossPostWarning, diff --git a/lib/src/features/posting/ui/pages/video_review_page.dart b/lib/src/features/posting/ui/pages/video_review_page.dart index c26d048b..6ef43f01 100644 --- a/lib/src/features/posting/ui/pages/video_review_page.dart +++ b/lib/src/features/posting/ui/pages/video_review_page.dart @@ -13,7 +13,6 @@ import 'package:spark/src/core/design_system/templates/video_review_page_templat import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/core/network/atproto/atproto.dart'; import 'package:spark/src/core/routing/app_router.dart'; -import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; import 'package:spark/src/core/utils/error_messages.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/utils/logging/logger.dart'; @@ -51,7 +50,6 @@ class VideoReviewPage extends ConsumerStatefulWidget { class _VideoReviewPageState extends ConsumerState { final MentionController _descriptionController = MentionController(); bool _isPosting = false; - String _videoAltText = ''; bool _crosspostToBsky = false; late XFile _video; late final FeedRepository _feedRepository; @@ -158,18 +156,6 @@ class _VideoReviewPageState extends ConsumerState { return mounted; } - Future _editAltText() async { - final initialText = _videoAltText; - final result = await showDialog( - context: context, - builder: (context) => AltTextEditorDialog(initialAltText: initialText), - ); - if (result == null) return; - setState(() { - _videoAltText = result.trim(); - }); - } - void _startVideoUpload({bool notify = true}) { if (_isUploadingVideo) return; @@ -246,22 +232,6 @@ class _VideoReviewPageState extends ConsumerState { }; } - String _postLabel(AppLocalizations l10n) { - if (_uploadErrorMessage != null) return l10n.messageUploadFailed; - if (_uploadResult != null) return l10n.buttonPost; - final percent = (_uploadProgress * 100).round(); - switch (_uploadPhase) { - case _VideoUploadPhase.uploading: - return l10n.messageUploadingPercent(percent); - case _VideoUploadPhase.processing: - return l10n.messageProcessingVideo; - case _VideoUploadPhase.ready: - return l10n.buttonPost; - case null: - return l10n.messageUploadingVideo; - } - } - Future _postVideo() async { if (_isPosting || _isLeaving) return; final uploadResult = _uploadResult; @@ -285,7 +255,7 @@ class _VideoReviewPageState extends ConsumerState { final postRef = await postProcessedVideo( uploadResult: uploadResult, description: description, - altText: _videoAltText, + altText: '', aspectRatio: _videoAspectRatio, storyMode: widget.storyMode, soundRef: widget.soundRef, @@ -377,13 +347,12 @@ class _VideoReviewPageState extends ConsumerState { } }, child: VideoReviewPageTemplate( - title: l10n.pageTitleReviewVideo, + title: l10n.pageTitleReviewPost, onBack: () => unawaited(_close()), aspectRatio: ar, videoPreview: _playbackSession == null ? const Center(child: CircularProgressIndicator()) : VideoPlayer(_playbackSession!.videoController), - onAltEdit: _editAltText, uploadProgress: _uploadProgress, uploadStatusLabel: uploadStatusLabel, uploadIndeterminate: _uploadPhase == _VideoUploadPhase.processing, @@ -392,14 +361,12 @@ class _VideoReviewPageState extends ConsumerState { ? null : () => _startVideoUpload(), mentionController: _descriptionController, - onMentionsChanged: (mentions) { - // Mentions are automatically tracked in the controller - }, descriptionMaxChars: AppConstants.postDescriptionMaxChars, + showCaption: !widget.storyMode, showCrossPost: !widget.storyMode, crossPostValue: _crosspostToBsky, onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), - postLabel: _postLabel(l10n), + postLabel: l10n.buttonPost, isPosting: _isPosting, isOverLimit: isOverLimit, onPost: canPost diff --git a/test/src/core/design_system/templates/image_review_page_template_test.dart b/test/src/core/design_system/templates/image_review_page_template_test.dart index c600a5af..18073a6e 100644 --- a/test/src/core/design_system/templates/image_review_page_template_test.dart +++ b/test/src/core/design_system/templates/image_review_page_template_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; @@ -5,93 +8,260 @@ import 'package:spark/src/core/design_system/templates/image_review_page_templat import 'package:spark/src/core/l10n/app_localizations.dart'; void main() { - group('ImageReviewPageTemplate sound section', () { - testWidgets('shows add sound action when no sound is selected', ( - tester, - ) async { - var addTapped = false; + late Directory images; + late String imagePath; - await tester.pumpWidget( - _TestApp(child: _template(onAddSound: () => addTapped = true)), - ); + setUpAll(() { + images = Directory.systemTemp.createTempSync('photo-review-test-'); + imagePath = '${images.path}/photo.png'; + File(imagePath).writeAsBytesSync( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', + ), + ); + }); + tearDownAll(() => images.deleteSync(recursive: true)); - await tester.tap( - find.byWidgetPredicate( - (widget) => widget is AppButton && widget.label == 'Add sound', - ), + Future pumpReview( + WidgetTester tester, + Widget child, { + MediaQueryData? mediaQuery, + }) async { + await tester.pumpWidget(_TestApp(mediaQuery: mediaQuery, child: child)); + await tester.runAsync(() async { + await precacheImage( + FileImage(File(imagePath)), + tester.element(find.byType(ImageReviewPageTemplate)), ); - - expect(addTapped, isTrue); }); + await tester.pump(); + } + + testWidgets('sound row opens picker and selected sound can be removed', ( + tester, + ) async { + var addTapped = false; + var removeTapped = false; + await pumpReview(tester, _template(onAddSound: () => addTapped = true)); + await tester.ensureVisible(find.text('Add sound')); + await tester.tap(find.text('Add sound')); + expect(addTapped, isTrue); + + await pumpReview( + tester, + _template( + selectedSoundTitle: 'Summer Loop', + selectedSoundSubtitle: 'artist.sprk.so', + onAddSound: () {}, + onRemoveSound: () => removeTapped = true, + ), + ); + expect(find.text('Summer Loop'), findsOneWidget); + expect(find.text('artist.sprk.so'), findsOneWidget); + await tester.ensureVisible(find.byTooltip('Remove')); + await tester.tap(find.byTooltip('Remove')); + expect(removeTapped, isTrue); + }); - testWidgets('shows selected sound and supports removal', (tester) async { - var removeTapped = false; - - await tester.pumpWidget( - _TestApp( - child: _template( - selectedSoundTitle: 'Summer Loop', - selectedSoundSubtitle: 'artist.sprk.so', - onAddSound: () {}, - onRemoveSound: () => removeTapped = true, - ), + testWidgets( + 'each photo opens its editor directly and removes its own index', + (tester) async { + final edited = []; + final removed = []; + await pumpReview( + tester, + _template( + imagePaths: [imagePath, imagePath], + onTapEditImage: edited.add, + onRemoveImage: removed.add, ), ); - expect(find.text('Summer Loop'), findsOneWidget); - expect(find.text('artist.sprk.so'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Edit photo 2')); + expect(edited, [1]); + await tester.tap(find.bySemanticsLabel('Edit photo 1')); + expect(edited, [1, 0]); + await tester.longPress(find.bySemanticsLabel('Edit photo 2')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(removed, [1]); + }, + ); - await tester.tap(find.byTooltip('Remove')); + testWidgets('removing the last photo disables posting', (tester) async { + var paths = [imagePath]; + var posts = 0; + await pumpReview( + tester, + StatefulBuilder( + builder: (context, setState) => _template( + imagePaths: paths, + showAddMore: true, + onPost: () => posts++, + onRemoveImage: (index) => setState(() => paths = []), + ), + ), + ); + expect( + tester.widget(find.byType(AppButton)).onPressed, + isNotNull, + ); + await tester.longPress(find.bySemanticsLabel('Edit photo 1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(AppButton)).onPressed, isNull); + await tester.tap(find.byType(AppButton), warnIfMissed: false); + expect(posts, 0); + }); - expect(removeTapped, isTrue); - }); + testWidgets('photo limit prevents adding more photos', (tester) async { + var adds = 0; + await pumpReview( + tester, + _template( + imagePaths: [imagePath], + showAddMore: true, + canAddMore: false, + onAddMore: () => adds++, + ), + ); + await tester.tap(find.byTooltip('Photo limit reached')); + expect(adds, 0); + }); + + testWidgets('posting locks media, caption, sound and cross-post controls', ( + tester, + ) async { + var actions = 0; + await pumpReview( + tester, + _template( + imagePaths: [imagePath], + showAddMore: true, + isPosting: true, + onTapEditImage: (_) => actions++, + onRemoveImage: (_) => actions++, + onAddMore: () => actions++, + onAddSound: () => actions++, + onCrossPostChanged: (_) => actions++, + onPost: () => actions++, + ), + ); + await tester.tap( + find.bySemanticsLabel('Edit photo 1'), + warnIfMissed: false, + ); + await tester.longPress( + find.bySemanticsLabel('Edit photo 1'), + warnIfMissed: false, + ); + await tester.pump(); + await tester.tap(find.byTooltip('Add photos'), warnIfMissed: false); + await tester.ensureVisible(find.text('Add sound')); + await tester.tap(find.text('Add sound'), warnIfMissed: false); + await tester.ensureVisible(find.text('Also post to Bluesky')); + await tester.tap(find.text('Also post to Bluesky'), warnIfMissed: false); + await tester.tap(find.byType(AppButton), warnIfMissed: false); + expect(tester.widget(find.byType(TextField)).enabled, isFalse); + expect(find.text('Remove'), findsNothing); + expect(actions, 0); }); + + testWidgets( + 'caption scrolls above keyboard while Post stays in the safe area', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = TextEditingController(); + addTearDown(controller.dispose); + await pumpReview( + tester, + _template(imagePaths: [imagePath], descriptionController: controller), + mediaQuery: const MediaQueryData( + size: Size(320, 568), + padding: EdgeInsets.only(top: 44), + viewPadding: EdgeInsets.only(top: 44, bottom: 34), + viewInsets: EdgeInsets.only(bottom: 180), + textScaler: TextScaler.linear(2), + ), + ); + final originalCaptionTop = tester.getTopLeft(find.text('Caption')).dy; + final postBefore = tester.getRect(find.byType(AppButton)); + await tester.ensureVisible(find.byType(TextField)); + await tester.enterText( + find.byType(TextField), + 'A caption on a small phone', + ); + await tester.pump(); + expect(controller.text, 'A caption on a small phone'); + expect( + tester.getTopLeft(find.text('Caption')).dy, + lessThan(originalCaptionTop), + ); + final postAfter = tester.getRect(find.byType(AppButton)); + expect(postAfter, postBefore); + expect(postAfter.bottom, lessThanOrEqualTo(568 - 180)); + expect(postAfter.top, greaterThan(44)); + expect(find.byType(AppButton).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); } ImageReviewPageTemplate _template({ + List imagePaths = const [], + ValueChanged? onTapEditImage, + ValueChanged? onRemoveImage, + bool showAddMore = false, + bool canAddMore = true, + VoidCallback? onAddMore, + bool isPosting = false, + VoidCallback? onPost, + TextEditingController? descriptionController, String? selectedSoundTitle, String? selectedSoundSubtitle, VoidCallback? onAddSound, VoidCallback? onRemoveSound, -}) { - return ImageReviewPageTemplate( - title: 'Review', - onBack: () {}, - imagePaths: const [], - currentPage: 0, - onPageChanged: (_) {}, - onTapEditImage: (_) {}, - onAltEdit: (_) {}, - onRemoveImage: (_) {}, - showAddMore: false, - canAddMore: false, - imagesCount: 0, - maxImages: 12, - onAddMore: () {}, - descriptionMaxChars: 300, - crossPostValue: false, - onCrossPostChanged: (_) {}, - postLabel: 'Post', - onPost: () {}, - isPosting: false, - selectedSoundTitle: selectedSoundTitle, - selectedSoundSubtitle: selectedSoundSubtitle, - onAddSound: onAddSound, - onRemoveSound: onRemoveSound, - ); -} + ValueChanged? onCrossPostChanged, +}) => ImageReviewPageTemplate( + title: 'Review', + onBack: () {}, + imagePaths: imagePaths, + onTapEditImage: onTapEditImage ?? (_) {}, + onRemoveImage: onRemoveImage ?? (_) {}, + showAddMore: showAddMore, + canAddMore: canAddMore, + onAddMore: onAddMore ?? () {}, + descriptionController: descriptionController, + descriptionMaxChars: 300, + crossPostValue: false, + onCrossPostChanged: onCrossPostChanged ?? (_) {}, + postLabel: 'Post', + onPost: onPost ?? () {}, + isPosting: isPosting, + selectedSoundTitle: selectedSoundTitle, + selectedSoundSubtitle: selectedSoundSubtitle, + onAddSound: onAddSound, + onRemoveSound: onRemoveSound, +); class _TestApp extends StatelessWidget { - const _TestApp({required this.child}); + const _TestApp({required this.child, this.mediaQuery}); final Widget child; + final MediaQueryData? mediaQuery; @override - Widget build(BuildContext context) { - return MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: child, - ); - } + Widget build(BuildContext context) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: mediaQuery == null + ? null + : (context, child) => MediaQuery(data: mediaQuery!, child: child!), + home: child, + ); } diff --git a/test/src/core/design_system/templates/video_review_page_template_test.dart b/test/src/core/design_system/templates/video_review_page_template_test.dart new file mode 100644 index 00000000..2c984c31 --- /dev/null +++ b/test/src/core/design_system/templates/video_review_page_template_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/templates/video_review_page_template.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; + +void main() { + for (final aspectRatio in [9 / 16, 16 / 9]) { + testWidgets('bounds the full $aspectRatio video preview on a small phone', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _TestApp(child: _template(aspectRatio: aspectRatio)), + ); + + final preview = tester.getRect( + find.byKey(const ValueKey('video-preview')), + ); + expect(preview.width / preview.height, closeTo(aspectRatio, 0.01)); + expect(preview.height, lessThanOrEqualTo(340)); + expect(preview.center.dx, closeTo(160, 0.01)); + if (aspectRatio < 1) { + expect(preview.height, greaterThanOrEqualTo(220)); + } else { + expect(preview.width, closeTo(280, 0.01)); + } + expect( + tester.getTopLeft(find.text('Caption')).dy - preview.bottom, + closeTo(28, 0.01), + ); + expect(preview.left, greaterThanOrEqualTo(0)); + expect(preview.right, lessThanOrEqualTo(320)); + expect(find.byType(AppButton).hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('failed upload keeps retry and disabled Post visible', ( + tester, + ) async { + var retries = 0; + await tester.pumpWidget( + _TestApp( + child: _template( + onPost: null, + uploadStatusLabel: 'Upload failed', + hasUploadError: true, + onUploadRetry: () => retries++, + ), + ), + ); + expect(find.text('Upload failed').hitTestable(), findsOneWidget); + expect(tester.widget(find.byType(AppButton)).onPressed, isNull); + await tester.tap(find.text('Try again')); + expect(retries, 1); + }); + + testWidgets( + 'processing has indeterminate progress without a fake percentage', + (tester) async { + await tester.pumpWidget( + _TestApp( + child: _template( + uploadStatusLabel: 'Processing video', + uploadProgress: 0.75, + uploadIndeterminate: true, + ), + ), + ); + expect(find.text('Processing video').hitTestable(), findsOneWidget); + expect(find.textContaining('%'), findsNothing); + expect( + tester + .widget( + find.byType(LinearProgressIndicator), + ) + .value, + isNull, + ); + }, + ); + + testWidgets('upload status and Post fit with a keyboard and large text', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _TestApp( + mediaQuery: const MediaQueryData( + size: Size(320, 568), + padding: EdgeInsets.only(top: 44), + viewPadding: EdgeInsets.only(top: 44, bottom: 34), + viewInsets: EdgeInsets.only(bottom: 180), + textScaler: TextScaler.linear(2), + ), + child: _template( + uploadStatusLabel: 'Uploading video', + uploadProgress: 0.4, + ), + ), + ); + expect(find.text('Uploading video').hitTestable(), findsOneWidget); + expect(find.text('40%'), findsOneWidget); + expect(find.byType(AppButton).hitTestable(), findsOneWidget); + final post = tester.getRect(find.byType(AppButton)); + expect(post.bottom, lessThanOrEqualTo(568 - 180)); + expect(post.top, greaterThan(44)); + expect(tester.takeException(), isNull); + }); +} + +VideoReviewPageTemplate _template({ + double aspectRatio = 9 / 16, + VoidCallback? onPost = _noop, + String? uploadStatusLabel, + double? uploadProgress, + bool uploadIndeterminate = false, + bool hasUploadError = false, + VoidCallback? onUploadRetry, +}) => VideoReviewPageTemplate( + title: 'Review', + onBack: () {}, + videoPreview: const SizedBox.expand(key: ValueKey('video-preview')), + aspectRatio: aspectRatio, + descriptionMaxChars: 300, + crossPostValue: false, + onCrossPostChanged: (_) {}, + postLabel: 'Post', + onPost: onPost, + isPosting: false, + uploadStatusLabel: uploadStatusLabel, + uploadProgress: uploadProgress, + uploadIndeterminate: uploadIndeterminate, + hasUploadError: hasUploadError, + onUploadRetry: onUploadRetry, +); + +void _noop() {} + +class _TestApp extends StatelessWidget { + const _TestApp({required this.child, this.mediaQuery}); + + final Widget child; + final MediaQueryData? mediaQuery; + + @override + Widget build(BuildContext context) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: mediaQuery == null + ? null + : (context, child) => MediaQuery(data: mediaQuery!, child: child!), + home: child, + ); +} diff --git a/widgetbook/lib/templates/page_templates.dart b/widgetbook/lib/templates/page_templates.dart index 9cd7f34e..9f5374d4 100644 --- a/widgetbook/lib/templates/page_templates.dart +++ b/widgetbook/lib/templates/page_templates.dart @@ -100,15 +100,10 @@ Widget buildImageReviewPageTemplateUseCase(BuildContext context) { title: 'New post', onBack: () {}, imagePaths: const [], - currentPage: 0, - onPageChanged: (_) {}, onTapEditImage: (_) {}, - onAltEdit: (_) {}, onRemoveImage: (_) {}, showAddMore: true, canAddMore: true, - imagesCount: 0, - maxImages: 4, onAddMore: () {}, descriptionMaxChars: 300, postLabel: 'Post', @@ -139,7 +134,6 @@ Widget buildVideoReviewPageTemplateUseCase(BuildContext context) { title: 'New video', onBack: () {}, videoPreview: const ColoredBox(color: Colors.black), - onAltEdit: () {}, descriptionMaxChars: 300, postLabel: 'Post', onPost: () {}, -- 2.51.2