mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: in app image viewer

+516 -16
+3
android/app/src/main/AndroidManifest.xml
··· 1 1 <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 2 + <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> 3 + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> 4 + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> 2 5 <application 3 6 android:label="lazurite" 4 7 android:name="${applicationName}"
+12 -6
docs/tasks/phase-4.md
··· 17 17 ## M13 — Media Playback & Download 18 18 19 19 - [x] Add `photo_view`, `video_player`, `chewie`, `dio`, `gal`, `permission_handler` to `pubspec.yaml` 20 - - [ ] `ImageViewerScreen` — full-screen `PageView` of `PhotoView` widgets loading `fullsize` URLs with hero animation from thumbnail 21 - - [ ] Page indicator for multi-image posts; alt text bar at the bottom of each page 22 - - [ ] Swipe-down-to-dismiss gesture on image viewer 23 - - [ ] Download button in image viewer toolbar — request permission, download via `dio` with progress indicator, save via `gal`, show snackbar result 24 - - [ ] Share button in image viewer toolbar via `share_plus` 25 - - [ ] Long-press context menu on image thumbnails in post cards — "Save image" and "Share" options 20 + 21 + ### Images 22 + 23 + - [x] `ImageViewerScreen` — full-screen `PageView` of `PhotoView` widgets loading `fullsize` URLs with hero animation from thumbnail 24 + - [x] Page indicator for multi-image posts; alt text bar at the bottom of each page 25 + - [x] Swipe-down-to-dismiss gesture on image viewer 26 + - [x] Download button in image viewer toolbar — request permission, download via `dio` with progress indicator, save via `gal`, show snackbar result 27 + - [x] Share button in image viewer toolbar via `share_plus` 28 + - [x] Long-press context menu on image thumbnails in post cards — "Save image" and "Share" options 29 + 30 + ### Video Player 31 + 26 32 - [ ] `VideoPlayerScreen` — `chewie` wrapping `VideoPlayerController.networkUrl` with HLS `playlist` URL 27 33 - [ ] Video player uses embed `aspectRatio` when available, defaults to 16:9 28 34 - [ ] Video thumbnail as placeholder until player initialises; controller disposed on screen pop
+32
ios/Podfile.lock
··· 2 2 - connectivity_plus (0.0.1): 3 3 - Flutter 4 4 - Flutter (1.0.0) 5 + - gal (1.0.0): 6 + - Flutter 7 + - FlutterMacOS 5 8 - image_picker_ios (0.0.1): 6 9 - Flutter 10 + - package_info_plus (0.4.5): 11 + - Flutter 7 12 - path_provider_foundation (0.0.1): 8 13 - Flutter 9 14 - FlutterMacOS 15 + - permission_handler_apple (9.3.0): 16 + - Flutter 10 17 - share_plus (0.0.1): 11 18 - Flutter 12 19 - sqlite3 (3.52.0): ··· 36 43 - sqlite3/session 37 44 - url_launcher_ios (0.0.1): 38 45 - Flutter 46 + - video_player_avfoundation (0.0.1): 47 + - Flutter 48 + - FlutterMacOS 49 + - wakelock_plus (0.0.1): 50 + - Flutter 39 51 - workmanager (0.0.1): 40 52 - Flutter 41 53 42 54 DEPENDENCIES: 43 55 - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) 44 56 - Flutter (from `Flutter`) 57 + - gal (from `.symlinks/plugins/gal/darwin`) 45 58 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 59 + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 46 60 - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 61 + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) 47 62 - share_plus (from `.symlinks/plugins/share_plus/ios`) 48 63 - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) 49 64 - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 65 + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) 66 + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) 50 67 - workmanager (from `.symlinks/plugins/workmanager/ios`) 51 68 52 69 SPEC REPOS: ··· 58 75 :path: ".symlinks/plugins/connectivity_plus/ios" 59 76 Flutter: 60 77 :path: Flutter 78 + gal: 79 + :path: ".symlinks/plugins/gal/darwin" 61 80 image_picker_ios: 62 81 :path: ".symlinks/plugins/image_picker_ios/ios" 82 + package_info_plus: 83 + :path: ".symlinks/plugins/package_info_plus/ios" 63 84 path_provider_foundation: 64 85 :path: ".symlinks/plugins/path_provider_foundation/darwin" 86 + permission_handler_apple: 87 + :path: ".symlinks/plugins/permission_handler_apple/ios" 65 88 share_plus: 66 89 :path: ".symlinks/plugins/share_plus/ios" 67 90 sqlite3_flutter_libs: 68 91 :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" 69 92 url_launcher_ios: 70 93 :path: ".symlinks/plugins/url_launcher_ios/ios" 94 + video_player_avfoundation: 95 + :path: ".symlinks/plugins/video_player_avfoundation/darwin" 96 + wakelock_plus: 97 + :path: ".symlinks/plugins/wakelock_plus/ios" 71 98 workmanager: 72 99 :path: ".symlinks/plugins/workmanager/ios" 73 100 74 101 SPEC CHECKSUMS: 75 102 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd 76 103 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 104 + gal: baecd024ebfd13c441269ca7404792a7152fde89 77 105 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 106 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 78 107 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 108 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 79 109 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a 80 110 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 81 111 sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab 82 112 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b 113 + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a 114 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 83 115 workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e 84 116 85 117 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+18
ios/Runner.xcodeproj/project.pbxproj
··· 199 199 9705A1C41CF9048500538489 /* Embed Frameworks */, 200 200 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 201 201 D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */, 202 + 46A625365BA943162BC0528F /* [CP] Copy Pods Resources */, 202 203 ); 203 204 buildRules = ( 204 205 ); ··· 307 308 runOnlyForDeploymentPostprocessing = 0; 308 309 shellPath = /bin/sh; 309 310 shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; 311 + }; 312 + 46A625365BA943162BC0528F /* [CP] Copy Pods Resources */ = { 313 + isa = PBXShellScriptBuildPhase; 314 + buildActionMask = 2147483647; 315 + files = ( 316 + ); 317 + inputFileListPaths = ( 318 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", 319 + ); 320 + name = "[CP] Copy Pods Resources"; 321 + outputFileListPaths = ( 322 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", 323 + ); 324 + runOnlyForDeploymentPostprocessing = 0; 325 + shellPath = /bin/sh; 326 + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; 327 + showEnvVarsInLog = 0; 310 328 }; 311 329 9740EEB61CF901F6004384FC /* Run Script */ = { 312 330 isa = PBXShellScriptBuildPhase;
+2
ios/Runner/Info.plist
··· 62 62 <string>LaunchScreen</string> 63 63 <key>UIMainStoryboardFile</key> 64 64 <string>Main</string> 65 + <key>NSPhotoLibraryAddUsageDescription</key> 66 + <string>Lazurite saves images and videos to your photo library when you download media.</string> 65 67 <key>UISupportedInterfaceOrientations</key> 66 68 <array> 67 69 <string>UIInterfaceOrientationPortrait</string>
+10
lib/core/router/app_router.dart
··· 15 15 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 16 16 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 17 17 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 18 + import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 19 + import 'package:lazurite/features/feed/presentation/media/image_viewer_screen.dart'; 18 20 import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; 19 21 import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 20 22 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; ··· 96 98 builder: (context, state) { 97 99 final uri = state.uri.queryParameters['uri'] ?? ''; 98 100 return PostThreadScreen(postUri: Uri.decodeComponent(uri)); 101 + }, 102 + ), 103 + GoRoute( 104 + path: '/images', 105 + parentNavigatorKey: _rootNavigatorKey, 106 + builder: (context, state) { 107 + final args = state.extra as ImageViewerRouteArgs; 108 + return ImageViewerScreen(args: args); 99 109 }, 100 110 ), 101 111 GoRoute(
+61 -2
lib/core/router/app_shell.dart
··· 1 + import 'package:bluesky/bluesky.dart'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:flutter_bloc/flutter_bloc.dart'; 3 4 import 'package:go_router/go_router.dart'; ··· 69 70 final tokens = rootContext.watch<AuthBloc>().state.tokens; 70 71 final displayName = tokens?.displayName ?? tokens?.handle ?? 'Guest'; 71 72 final handle = tokens?.handle ?? 'Sign in required'; 73 + final did = tokens?.did; 72 74 final initials = _initialsFor(tokens?.displayName ?? tokens?.handle ?? 'L'); 73 75 final drawerWidth = (MediaQuery.sizeOf(context).width * 0.82).clamp(280.0, 320.0).toDouble(); 74 76 ··· 105 107 ), 106 108 child: Row( 107 109 children: [ 108 - // TODO: Add user avatar (keep initials as fallback) 109 - CircleAvatar(radius: 24, child: Text(initials)), 110 + _MenuProfileAvatar(did: did, initials: initials), 110 111 const SizedBox(width: 12), 111 112 Expanded( 112 113 child: Column( ··· 260 261 } 261 262 262 263 return parts.map((part) => part.characters.first.toUpperCase()).join(); 264 + } 265 + } 266 + 267 + class _MenuProfileAvatar extends StatefulWidget { 268 + const _MenuProfileAvatar({required this.did, required this.initials}); 269 + 270 + final String? did; 271 + final String initials; 272 + 273 + @override 274 + State<_MenuProfileAvatar> createState() => _MenuProfileAvatarState(); 275 + } 276 + 277 + class _MenuProfileAvatarState extends State<_MenuProfileAvatar> { 278 + Future<String?>? _avatarFuture; 279 + 280 + @override 281 + void initState() { 282 + super.initState(); 283 + _avatarFuture = _loadAvatar(); 284 + } 285 + 286 + @override 287 + void didUpdateWidget(covariant _MenuProfileAvatar oldWidget) { 288 + super.didUpdateWidget(oldWidget); 289 + if (oldWidget.did != widget.did) { 290 + _avatarFuture = _loadAvatar(); 291 + } 292 + } 293 + 294 + Future<String?> _loadAvatar() async { 295 + final did = widget.did; 296 + if (did == null || did.isEmpty) { 297 + return null; 298 + } 299 + 300 + try { 301 + final profile = await context.read<Bluesky>().actor.getProfile(actor: did); 302 + return profile.data.avatar; 303 + } catch (_) { 304 + return null; 305 + } 306 + } 307 + 308 + @override 309 + Widget build(BuildContext context) { 310 + return FutureBuilder<String?>( 311 + future: _avatarFuture, 312 + builder: (context, snapshot) { 313 + final avatarUrl = snapshot.data; 314 + return CircleAvatar( 315 + radius: 24, 316 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 317 + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 318 + child: avatarUrl == null ? Text(widget.initials) : null, 319 + ); 320 + }, 321 + ); 263 322 } 264 323 } 265 324
+84
lib/features/feed/presentation/media/image_media_actions.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:dio/dio.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:gal/gal.dart'; 6 + import 'package:path/path.dart' as p; 7 + import 'package:path_provider/path_provider.dart'; 8 + import 'package:permission_handler/permission_handler.dart'; 9 + import 'package:share_plus/share_plus.dart'; 10 + 11 + class ImageMediaActions { 12 + ImageMediaActions._(); 13 + 14 + static Future<void> shareImage(BuildContext context, String imageUrl) async { 15 + await Share.share(imageUrl); 16 + } 17 + 18 + static Future<void> downloadImage( 19 + BuildContext context, 20 + String imageUrl, { 21 + String? suggestedName, 22 + ValueChanged<double>? onProgress, 23 + }) async { 24 + final messenger = ScaffoldMessenger.of(context); 25 + 26 + try { 27 + final granted = await _requestPhotoPermission(); 28 + if (!granted) { 29 + messenger.showSnackBar(const SnackBar(content: Text('Photo access is required to save images.'))); 30 + return; 31 + } 32 + 33 + final tempDir = await getTemporaryDirectory(); 34 + final fileName = _normalizedFileName(imageUrl, suggestedName: suggestedName); 35 + final filePath = p.join(tempDir.path, fileName); 36 + final dio = Dio(); 37 + 38 + await dio.download( 39 + imageUrl, 40 + filePath, 41 + onReceiveProgress: (received, total) { 42 + if (total <= 0) { 43 + return; 44 + } 45 + onProgress?.call(received / total); 46 + }, 47 + ); 48 + 49 + await Gal.putImage(filePath); 50 + try { 51 + await File(filePath).delete(); 52 + } catch (_) {} 53 + messenger.showSnackBar(const SnackBar(content: Text('Image saved to your gallery.'))); 54 + } catch (error) { 55 + messenger.showSnackBar(SnackBar(content: Text('Failed to save image: $error'))); 56 + } finally { 57 + onProgress?.call(0); 58 + } 59 + } 60 + 61 + static Future<bool> _requestPhotoPermission() async { 62 + if (Platform.isIOS) { 63 + final status = await Permission.photosAddOnly.request(); 64 + return status.isGranted || status.isLimited; 65 + } 66 + 67 + if (Platform.isAndroid) { 68 + final statuses = await [Permission.photos, Permission.storage].request(); 69 + return statuses.values.any((status) => status.isGranted || status.isLimited); 70 + } 71 + 72 + return true; 73 + } 74 + 75 + static String _normalizedFileName(String imageUrl, {String? suggestedName}) { 76 + final uri = Uri.tryParse(imageUrl); 77 + final rawName = suggestedName ?? (uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'); 78 + final sanitizedName = rawName.split('?').first; 79 + if (p.extension(sanitizedName).isNotEmpty) { 80 + return sanitizedName; 81 + } 82 + return '$sanitizedName.jpg'; 83 + } 84 + }
+15
lib/features/feed/presentation/media/image_viewer_route_args.dart
··· 1 + class ImageViewerRouteArgs { 2 + const ImageViewerRouteArgs({required this.images, required this.initialIndex}); 3 + 4 + final List<ImageViewerItem> images; 5 + final int initialIndex; 6 + } 7 + 8 + class ImageViewerItem { 9 + const ImageViewerItem({required this.fullsizeUrl, required this.thumbnailUrl, required this.heroTag, this.altText}); 10 + 11 + final String fullsizeUrl; 12 + final String thumbnailUrl; 13 + final String heroTag; 14 + final String? altText; 15 + }
+202
lib/features/feed/presentation/media/image_viewer_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:photo_view/photo_view_gallery.dart'; 3 + import 'package:photo_view/photo_view.dart'; 4 + import 'package:lazurite/features/feed/presentation/media/image_media_actions.dart'; 5 + import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 6 + 7 + class ImageViewerScreen extends StatefulWidget { 8 + const ImageViewerScreen({super.key, required this.args}); 9 + 10 + final ImageViewerRouteArgs args; 11 + 12 + @override 13 + State<ImageViewerScreen> createState() => _ImageViewerScreenState(); 14 + } 15 + 16 + class _ImageViewerScreenState extends State<ImageViewerScreen> { 17 + late final PageController _pageController; 18 + late int _currentIndex; 19 + double _dragOffset = 0; 20 + double _downloadProgress = 0; 21 + bool _isDownloading = false; 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _currentIndex = widget.args.initialIndex; 27 + _pageController = PageController(initialPage: _currentIndex); 28 + } 29 + 30 + @override 31 + void dispose() { 32 + _pageController.dispose(); 33 + super.dispose(); 34 + } 35 + 36 + @override 37 + Widget build(BuildContext context) { 38 + final image = widget.args.images[_currentIndex]; 39 + final theme = Theme.of(context); 40 + final progressValue = _downloadProgress > 0 && _downloadProgress < 1 ? _downloadProgress : null; 41 + final backgroundOpacity = (1 - (_dragOffset.abs() / 240)).clamp(0.45, 1.0); 42 + 43 + return Scaffold( 44 + backgroundColor: Colors.black.withValues(alpha: backgroundOpacity), 45 + extendBodyBehindAppBar: true, 46 + appBar: AppBar( 47 + backgroundColor: Colors.black26, 48 + foregroundColor: Colors.white, 49 + elevation: 0, 50 + leading: IconButton( 51 + tooltip: 'Close', 52 + onPressed: () => Navigator.of(context).maybePop(), 53 + icon: const Icon(Icons.close), 54 + ), 55 + actions: [ 56 + IconButton( 57 + tooltip: 'Download', 58 + onPressed: _isDownloading ? null : _downloadCurrentImage, 59 + icon: _isDownloading 60 + ? SizedBox.square( 61 + dimension: 22, 62 + child: CircularProgressIndicator(value: progressValue, strokeWidth: 2.4, color: Colors.white), 63 + ) 64 + : const Icon(Icons.download_outlined), 65 + ), 66 + IconButton( 67 + tooltip: 'Share', 68 + onPressed: () => ImageMediaActions.shareImage(context, image.fullsizeUrl), 69 + icon: const Icon(Icons.share_outlined), 70 + ), 71 + ], 72 + ), 73 + body: GestureDetector( 74 + behavior: HitTestBehavior.opaque, 75 + onVerticalDragUpdate: (details) { 76 + setState(() { 77 + _dragOffset += details.delta.dy; 78 + }); 79 + }, 80 + onVerticalDragEnd: (details) { 81 + final velocity = details.primaryVelocity ?? 0; 82 + if (_dragOffset.abs() > 120 || velocity.abs() > 900) { 83 + Navigator.of(context).maybePop(); 84 + return; 85 + } 86 + setState(() { 87 + _dragOffset = 0; 88 + }); 89 + }, 90 + onVerticalDragCancel: () { 91 + setState(() { 92 + _dragOffset = 0; 93 + }); 94 + }, 95 + child: Stack( 96 + children: [ 97 + Transform.translate( 98 + offset: Offset(0, _dragOffset), 99 + child: PhotoViewGallery.builder( 100 + pageController: _pageController, 101 + itemCount: widget.args.images.length, 102 + backgroundDecoration: const BoxDecoration(color: Colors.transparent), 103 + onPageChanged: (index) { 104 + setState(() { 105 + _currentIndex = index; 106 + }); 107 + }, 108 + loadingBuilder: (context, event) { 109 + final expected = event?.expectedTotalBytes; 110 + final value = expected != null && expected > 0 ? event!.cumulativeBytesLoaded / expected : null; 111 + return Center( 112 + child: CircularProgressIndicator(value: value, color: Colors.white), 113 + ); 114 + }, 115 + builder: (context, index) { 116 + final item = widget.args.images[index]; 117 + return PhotoViewGalleryPageOptions( 118 + imageProvider: NetworkImage(item.fullsizeUrl), 119 + heroAttributes: PhotoViewHeroAttributes(tag: item.heroTag), 120 + minScale: PhotoViewComputedScale.contained, 121 + maxScale: PhotoViewComputedScale.covered * 2.6, 122 + errorBuilder: (context, error, stackTrace) => 123 + Center(child: Icon(Icons.broken_image_outlined, color: theme.colorScheme.onSurface, size: 40)), 124 + ); 125 + }, 126 + ), 127 + ), 128 + Positioned( 129 + left: 16, 130 + right: 16, 131 + bottom: 24, 132 + child: Column( 133 + mainAxisSize: MainAxisSize.min, 134 + children: [ 135 + if (widget.args.images.length > 1) 136 + Container( 137 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 138 + decoration: BoxDecoration( 139 + color: Colors.black.withValues(alpha: 0.55), 140 + borderRadius: BorderRadius.circular(999), 141 + ), 142 + child: Text( 143 + '${_currentIndex + 1} / ${widget.args.images.length}', 144 + style: theme.textTheme.labelLarge?.copyWith(color: Colors.white), 145 + ), 146 + ), 147 + if ((image.altText?.trim().isNotEmpty ?? false)) ...[ 148 + const SizedBox(height: 12), 149 + Container( 150 + width: double.infinity, 151 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), 152 + decoration: BoxDecoration( 153 + color: Colors.black.withValues(alpha: 0.6), 154 + borderRadius: BorderRadius.circular(16), 155 + ), 156 + child: Text( 157 + image.altText!, 158 + maxLines: 3, 159 + overflow: TextOverflow.ellipsis, 160 + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white), 161 + ), 162 + ), 163 + ], 164 + ], 165 + ), 166 + ), 167 + ], 168 + ), 169 + ), 170 + ); 171 + } 172 + 173 + Future<void> _downloadCurrentImage() async { 174 + setState(() { 175 + _isDownloading = true; 176 + _downloadProgress = 0; 177 + }); 178 + 179 + final image = widget.args.images[_currentIndex]; 180 + await ImageMediaActions.downloadImage( 181 + context, 182 + image.fullsizeUrl, 183 + onProgress: (value) { 184 + if (!mounted) { 185 + return; 186 + } 187 + setState(() { 188 + _downloadProgress = value; 189 + }); 190 + }, 191 + ); 192 + 193 + if (!mounted) { 194 + return; 195 + } 196 + 197 + setState(() { 198 + _isDownloading = false; 199 + _downloadProgress = 0; 200 + }); 201 + } 202 + }
+77 -8
lib/features/feed/presentation/widgets/post_card.dart
··· 10 10 import 'package:go_router/go_router.dart'; 11 11 import 'package:intl/intl.dart'; 12 12 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 13 + import 'package:lazurite/features/feed/presentation/media/image_media_actions.dart'; 14 + import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; 13 15 import 'package:url_launcher/url_launcher.dart'; 14 16 15 17 class PostCard extends StatelessWidget { ··· 200 202 Widget _buildImagesEmbed(BuildContext context, List<EmbedImagesViewImage> images) { 201 203 final crossAxisCount = images.length == 1 ? 1 : 2; 202 204 final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0; 205 + final postUri = feedViewPost.post.uri.toString(); 203 206 204 207 return ClipRRect( 205 208 borderRadius: BorderRadius.circular(16), ··· 215 218 ), 216 219 itemBuilder: (context, index) { 217 220 final image = images[index]; 218 - return InkWell( 219 - onTap: () => _launchExternal(Uri.parse(image.fullsize)), 220 - child: Image.network( 221 - image.thumb, 222 - fit: BoxFit.cover, 223 - errorBuilder: (_, _, _) => ColoredBox( 224 - color: Theme.of(context).colorScheme.surfaceContainerHighest, 225 - child: const Center(child: Icon(Icons.image_not_supported_outlined)), 221 + final heroTag = _imageHeroTag(postUri, index); 222 + 223 + return GestureDetector( 224 + onLongPressStart: (details) => _showImageContextMenu(context, details.globalPosition, image: image), 225 + child: InkWell( 226 + onTap: () => _openImageViewer(context, images, initialIndex: index), 227 + child: Hero( 228 + tag: heroTag, 229 + child: Image.network( 230 + image.thumb, 231 + fit: BoxFit.cover, 232 + errorBuilder: (_, _, _) => ColoredBox( 233 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 234 + child: const Center(child: Icon(Icons.image_not_supported_outlined)), 235 + ), 236 + ), 226 237 ), 227 238 ), 228 239 ); ··· 509 520 510 521 return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 511 522 } 523 + 524 + void _openImageViewer(BuildContext context, List<EmbedImagesViewImage> images, {required int initialIndex}) { 525 + GoRouter.maybeOf(context)?.push( 526 + '/images', 527 + extra: ImageViewerRouteArgs( 528 + images: [ 529 + for (var i = 0; i < images.length; i++) 530 + ImageViewerItem( 531 + fullsizeUrl: images[i].fullsize, 532 + thumbnailUrl: images[i].thumb, 533 + altText: images[i].alt, 534 + heroTag: _imageHeroTag(feedViewPost.post.uri.toString(), i), 535 + ), 536 + ], 537 + initialIndex: initialIndex, 538 + ), 539 + ); 540 + } 541 + 542 + Future<void> _showImageContextMenu( 543 + BuildContext context, 544 + Offset globalPosition, { 545 + required EmbedImagesViewImage image, 546 + }) async { 547 + final selected = await showMenu<_ImageThumbnailAction>( 548 + context: context, 549 + position: RelativeRect.fromLTRB(globalPosition.dx, globalPosition.dy, globalPosition.dx, globalPosition.dy), 550 + items: const [ 551 + PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.save, child: Text('Save image')), 552 + PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.share, child: Text('Share')), 553 + ], 554 + ); 555 + 556 + if (!context.mounted || selected == null) { 557 + return; 558 + } 559 + 560 + switch (selected) { 561 + case _ImageThumbnailAction.save: 562 + await ImageMediaActions.downloadImage( 563 + context, 564 + image.fullsize, 565 + suggestedName: _downloadFileName(image.fullsize), 566 + ); 567 + case _ImageThumbnailAction.share: 568 + await ImageMediaActions.shareImage(context, image.fullsize); 569 + } 570 + } 571 + 572 + String _imageHeroTag(String postUri, int index) => 'post-image-$postUri-$index'; 573 + 574 + String _downloadFileName(String url) { 575 + final uri = Uri.tryParse(url); 576 + final segment = uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'; 577 + return segment.isEmpty ? 'image.jpg' : segment; 578 + } 512 579 } 513 580 514 581 Future<void> _launchExternal(Uri url) async { 515 582 await launchUrl(url, mode: LaunchMode.externalApplication); 516 583 } 584 + 585 + enum _ImageThumbnailAction { save, share }