this repo has no description

Compare changes

Choose any two refs to compare.

+591 -363
+2 -1
.vscode/settings.json
··· 2 2 "editor.codeActionsOnSave": { 3 3 "source.organizeImports": "always" 4 4 }, 5 - "dart.lineLength": 100 5 + "dart.lineLength": 100, 6 + "dart.flutterHotReloadOnSave": "all" 6 7 }
+15 -10
lib/providers/gallery_cache_provider.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:io'; 3 3 4 - import 'package:bluesky_text/bluesky_text.dart'; 5 4 import 'package:flutter/foundation.dart'; 6 5 import 'package:grain/models/gallery_photo.dart'; 7 6 import 'package:grain/models/procedures/apply_alts_update.dart'; ··· 43 42 44 43 void setGalleriesForActor(String did, List<Gallery> galleries) { 45 44 setGalleries(galleries); 46 - // Optionally, you could keep a mapping of actor DID to gallery URIs if needed 47 - } 48 - 49 - Future<List<Map<String, dynamic>>> _extractFacets(String text) async { 50 - final blueskyText = BlueskyText(text); 51 - final entities = blueskyText.entities; 52 - final facets = await entities.toFacets(); 53 - return List<Map<String, dynamic>>.from(facets); 54 45 } 55 46 56 47 Future<void> toggleFavorite(String uri) async { ··· 109 100 required List<XFile> xfiles, 110 101 int? startPosition, 111 102 bool includeExif = true, 103 + void Function(int imageIndex, double progress)? onProgress, 112 104 }) async { 113 105 // Fetch the latest gallery from the API to avoid stale state 114 106 final latestGallery = await apiService.getGallery(uri: galleryUri); ··· 120 112 final int positionOffset = startPosition ?? initialCount; 121 113 final List<String> photoUris = []; 122 114 int position = positionOffset; 123 - for (final xfile in xfiles) { 115 + for (int i = 0; i < xfiles.length; i++) { 116 + final xfile = xfiles[i]; 117 + // Report progress if callback is provided 118 + onProgress?.call(i, 0.0); 119 + 124 120 final file = File(xfile.path); 125 121 // Parse EXIF if requested 126 122 final exif = includeExif ? await parseAndNormalizeExif(file: file) : null; 123 + 124 + // Simulate progress steps 125 + for (int p = 1; p <= 10; p++) { 126 + await Future.delayed(const Duration(milliseconds: 30)); 127 + onProgress?.call(i, p / 10.0); 128 + } 129 + 127 130 // Resize the image 128 131 final resizedResult = await compute<File, ResizeResult>((f) => resizeImage(file: f), file); 129 132 // Upload the blob ··· 174 177 required String description, 175 178 required List<XFile> xfiles, 176 179 bool includeExif = true, 180 + void Function(int imageIndex, double progress)? onProgress, 177 181 }) async { 178 182 final res = await apiService.createGallery( 179 183 request: CreateGalleryRequest(title: title, description: description), ··· 183 187 galleryUri: res.galleryUri, 184 188 xfiles: xfiles, 185 189 includeExif: includeExif, 190 + onProgress: onProgress, 186 191 ); 187 192 return (res.galleryUri, photoUris); 188 193 }
+172 -130
lib/widgets/bottom_nav_bar.dart
··· 1 + import 'dart:async'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 4 import 'package:font_awesome_flutter/font_awesome_flutter.dart'; ··· 9 10 import 'package:grain/providers/profile_provider.dart'; 10 11 import 'package:grain/widgets/app_image.dart'; 11 12 12 - class BottomNavBar extends ConsumerWidget { 13 + class BottomNavBar extends ConsumerStatefulWidget { 13 14 final int navIndex; 14 15 final VoidCallback onHome; 15 16 final VoidCallback onExplore; ··· 26 27 }); 27 28 28 29 @override 29 - Widget build(BuildContext context, WidgetRef ref) { 30 + ConsumerState<BottomNavBar> createState() => _BottomNavBarState(); 31 + } 32 + 33 + class _BottomNavBarState extends ConsumerState<BottomNavBar> { 34 + int _pressedIndex = -1; 35 + 36 + void _onHoldTap(int index, VoidCallback callback) { 37 + setState(() => _pressedIndex = index); 38 + callback(); 39 + Future.delayed(const Duration(milliseconds: 200), () { 40 + setState(() => _pressedIndex = -1); 41 + }); 42 + } 43 + 44 + Widget _buildNavItem({ 45 + required int index, 46 + required Widget icon, 47 + required VoidCallback onHoldComplete, 48 + }) { 49 + return Expanded( 50 + child: _NavItem( 51 + index: index, 52 + isPressed: _pressedIndex == index, 53 + icon: icon, 54 + onHoldComplete: () => _onHoldTap(index, onHoldComplete), 55 + ), 56 + ); 57 + } 58 + 59 + @override 60 + Widget build(BuildContext context) { 61 + final theme = Theme.of(context); 30 62 final did = apiService.currentUser?.did; 31 63 final asyncProfile = did != null 32 64 ? ref.watch(profileNotifierProvider(did)) ··· 37 69 orElse: () => null, 38 70 ); 39 71 40 - final theme = Theme.of(context); 41 - 42 - // Get unread notifications count 43 72 final notifications = ref.watch(notificationsProvider); 44 73 final unreadCount = notifications.maybeWhen( 45 - data: (list) => list.where((n) => n.isRead == false).length, 74 + data: (list) => list.where((n) => !n.isRead).length, 46 75 orElse: () => 0, 47 76 ); 48 77 49 78 return Container( 50 79 decoration: BoxDecoration( 51 - color: Theme.of(context).scaffoldBackgroundColor, 52 - border: Border(top: BorderSide(color: Theme.of(context).dividerColor, width: 1)), 80 + color: theme.scaffoldBackgroundColor, 81 + border: Border(top: BorderSide(color: theme.dividerColor, width: 1)), 53 82 ), 54 - height: 42 + MediaQuery.of(context).padding.bottom, 83 + height: 56 + MediaQuery.of(context).padding.bottom, 84 + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), 55 85 child: Row( 56 - mainAxisAlignment: MainAxisAlignment.spaceAround, 57 86 children: [ 58 - Expanded( 59 - child: GestureDetector( 60 - behavior: HitTestBehavior.opaque, 61 - onTap: onHome, 62 - child: SizedBox( 63 - height: 42 + MediaQuery.of(context).padding.bottom, 64 - child: Transform.translate( 65 - offset: const Offset(0, -10), 66 - child: Center( 67 - child: FaIcon( 68 - AppIcons.house, 69 - size: 20, 70 - color: navIndex == 0 71 - ? AppTheme.primaryColor 72 - : Theme.of(context).colorScheme.onSurfaceVariant, 73 - ), 74 - ), 75 - ), 76 - ), 87 + _buildNavItem( 88 + index: 0, 89 + onHoldComplete: widget.onHome, 90 + icon: FaIcon( 91 + AppIcons.house, 92 + size: 20, 93 + color: widget.navIndex == 0 94 + ? AppTheme.primaryColor 95 + : theme.colorScheme.onSurfaceVariant, 77 96 ), 78 97 ), 79 - Expanded( 80 - child: GestureDetector( 81 - behavior: HitTestBehavior.opaque, 82 - onTap: onExplore, 83 - child: SizedBox( 84 - height: 42 + MediaQuery.of(context).padding.bottom, 85 - child: Transform.translate( 86 - offset: const Offset(0, -10), 87 - child: Center( 88 - child: FaIcon( 89 - AppIcons.magnifyingGlass, 90 - size: 20, 91 - color: navIndex == 1 92 - ? AppTheme.primaryColor 93 - : Theme.of(context).colorScheme.onSurfaceVariant, 94 - ), 95 - ), 96 - ), 97 - ), 98 + _buildNavItem( 99 + index: 1, 100 + onHoldComplete: widget.onExplore, 101 + icon: FaIcon( 102 + AppIcons.magnifyingGlass, 103 + size: 20, 104 + color: widget.navIndex == 1 105 + ? AppTheme.primaryColor 106 + : theme.colorScheme.onSurfaceVariant, 98 107 ), 99 108 ), 100 - Expanded( 101 - child: GestureDetector( 102 - behavior: HitTestBehavior.opaque, 103 - onTap: onNotifications, 104 - child: SizedBox( 105 - height: 42 + MediaQuery.of(context).padding.bottom, 106 - child: Transform.translate( 107 - offset: const Offset(0, -10), 108 - child: Stack( 109 - alignment: Alignment.center, 110 - children: [ 111 - Center( 112 - child: FaIcon( 113 - AppIcons.solidBell, 114 - size: 20, 115 - color: navIndex == 2 116 - ? AppTheme.primaryColor 117 - : Theme.of(context).colorScheme.onSurfaceVariant, 109 + _buildNavItem( 110 + index: 2, 111 + onHoldComplete: widget.onNotifications, 112 + icon: Stack( 113 + alignment: Alignment.center, 114 + children: [ 115 + FaIcon( 116 + AppIcons.solidBell, 117 + size: 20, 118 + color: widget.navIndex == 2 119 + ? AppTheme.primaryColor 120 + : theme.colorScheme.onSurfaceVariant, 121 + ), 122 + if (unreadCount > 0) 123 + Positioned( 124 + right: 0, 125 + top: 0, 126 + child: Container( 127 + padding: 128 + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), 129 + decoration: BoxDecoration( 130 + color: theme.colorScheme.primary, 131 + borderRadius: BorderRadius.circular(10), 132 + border: Border.all( 133 + color: theme.scaffoldBackgroundColor, 134 + width: 1, 118 135 ), 119 136 ), 120 - if (unreadCount > 0) 121 - Align( 122 - alignment: Alignment.center, 123 - child: Transform.translate( 124 - offset: const Offset(10, -10), 125 - child: Container( 126 - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), 127 - decoration: BoxDecoration( 128 - color: theme.colorScheme.primary, 129 - borderRadius: BorderRadius.circular(10), 130 - border: Border.all(color: theme.scaffoldBackgroundColor, width: 1), 131 - ), 132 - constraints: const BoxConstraints(minWidth: 16, minHeight: 16), 133 - child: Text( 134 - unreadCount > 99 ? '99+' : unreadCount.toString(), 135 - style: const TextStyle( 136 - color: Colors.white, 137 - fontSize: 10, 138 - fontWeight: FontWeight.bold, 139 - ), 140 - textAlign: TextAlign.center, 141 - ), 142 - ), 143 - ), 137 + constraints: 138 + const BoxConstraints(minWidth: 16, minHeight: 16), 139 + child: Text( 140 + unreadCount > 99 ? '99+' : unreadCount.toString(), 141 + style: const TextStyle( 142 + color: Colors.white, 143 + fontSize: 10, 144 + fontWeight: FontWeight.bold, 144 145 ), 145 - ], 146 + textAlign: TextAlign.center, 147 + ), 148 + ), 146 149 ), 147 - ), 148 - ), 150 + ], 149 151 ), 150 152 ), 151 - Expanded( 152 - child: GestureDetector( 153 - behavior: HitTestBehavior.opaque, 154 - onTap: onProfile, 155 - child: SizedBox( 156 - height: 42 + MediaQuery.of(context).padding.bottom, 157 - child: Transform.translate( 158 - offset: const Offset(0, -10), 159 - child: Center( 160 - child: avatarUrl != null && avatarUrl.isNotEmpty 161 - ? Container( 162 - width: 28, 163 - height: 28, 164 - alignment: Alignment.center, 165 - decoration: navIndex == 3 166 - ? BoxDecoration( 167 - shape: BoxShape.circle, 168 - border: Border.all(color: AppTheme.primaryColor, width: 2.2), 169 - ) 170 - : null, 171 - child: ClipOval( 172 - child: AppImage( 173 - url: avatarUrl, 174 - width: 24, 175 - height: 24, 176 - fit: BoxFit.cover, 177 - ), 178 - ), 179 - ) 180 - : FaIcon( 181 - navIndex == 3 ? AppIcons.solidUser : AppIcons.user, 182 - size: 16, 183 - color: navIndex == 3 184 - ? AppTheme.primaryColor 185 - : Theme.of(context).colorScheme.onSurfaceVariant, 186 - ), 187 - ), 153 + _buildNavItem( 154 + index: 3, 155 + onHoldComplete: widget.onProfile, 156 + icon: avatarUrl != null && avatarUrl.isNotEmpty 157 + ? Container( 158 + width: 28, 159 + height: 28, 160 + alignment: Alignment.center, 161 + decoration: widget.navIndex == 3 162 + ? BoxDecoration( 163 + shape: BoxShape.circle, 164 + border: Border.all( 165 + color: AppTheme.primaryColor, width: 2.2), 166 + ) 167 + : null, 168 + child: ClipOval( 169 + child: AppImage( 170 + url: avatarUrl, 171 + width: 24, 172 + height: 24, 173 + fit: BoxFit.cover, 188 174 ), 189 175 ), 176 + ) 177 + : FaIcon( 178 + widget.navIndex == 3 179 + ? AppIcons.solidUser 180 + : AppIcons.user, 181 + size: 16, 182 + color: widget.navIndex == 3 183 + ? AppTheme.primaryColor 184 + : theme.colorScheme.onSurfaceVariant, 190 185 ), 191 186 ), 192 187 ], ··· 194 189 ); 195 190 } 196 191 } 192 + 193 + class _NavItem extends StatefulWidget { 194 + final Widget icon; 195 + final VoidCallback onHoldComplete; 196 + final int index; 197 + final bool isPressed; 198 + 199 + const _NavItem({ 200 + required this.icon, 201 + required this.onHoldComplete, 202 + required this.index, 203 + required this.isPressed, 204 + }); 205 + 206 + @override 207 + State<_NavItem> createState() => _NavItemState(); 208 + } 209 + 210 + class _NavItemState extends State<_NavItem> { 211 + bool _pressed = false; 212 + 213 + @override 214 + Widget build(BuildContext context) { 215 + return GestureDetector( 216 + onTapDown: (_) { 217 + setState(() => _pressed = true); 218 + }, 219 + onTapUp: (_) { 220 + Future.delayed(const Duration(milliseconds: 200), () { 221 + setState(() => _pressed = false); 222 + }); 223 + }, 224 + onTapCancel: () { 225 + setState(() => _pressed = false); 226 + }, 227 + onTap: widget.onHoldComplete, 228 + behavior: HitTestBehavior.opaque, 229 + child: Center( 230 + child: AnimatedScale( 231 + scale: _pressed ? 0.85 : 1.0, 232 + duration: const Duration(milliseconds: 150), 233 + child: widget.icon, 234 + ), 235 + ), 236 + ); 237 + } 238 + }
+104
lib/widgets/upload_progress_overlay.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:flutter/material.dart'; 4 + 5 + import '../screens/create_gallery_page.dart'; 6 + 7 + class UploadProgressOverlay extends StatelessWidget { 8 + final List<GalleryImage> images; 9 + final int currentIndex; 10 + final double progress; // 0.0 - 1.0 11 + final bool visible; 12 + 13 + const UploadProgressOverlay({ 14 + super.key, 15 + required this.images, 16 + required this.currentIndex, 17 + required this.progress, 18 + this.visible = false, 19 + }); 20 + 21 + @override 22 + Widget build(BuildContext context) { 23 + if (!visible) return const SizedBox.shrink(); 24 + final theme = Theme.of(context); 25 + 26 + // Get the current image being uploaded 27 + final currentImage = currentIndex < images.length ? images[currentIndex] : null; 28 + 29 + // Calculate overall progress: completed images + current image's progress 30 + double overallProgress = 0.0; 31 + if (images.isNotEmpty) { 32 + overallProgress = (currentIndex + progress) / images.length; 33 + } 34 + 35 + return Material( 36 + color: Colors.transparent, 37 + child: Stack( 38 + children: [ 39 + Positioned.fill(child: Container(color: Colors.black.withOpacity(0.9))), 40 + Center( 41 + child: Padding( 42 + padding: const EdgeInsets.all(32), 43 + child: Column( 44 + mainAxisSize: MainAxisSize.min, 45 + mainAxisAlignment: MainAxisAlignment.center, 46 + children: [ 47 + Row( 48 + mainAxisSize: MainAxisSize.min, 49 + children: [ 50 + SizedBox( 51 + width: 24, 52 + height: 24, 53 + child: CircularProgressIndicator( 54 + strokeWidth: 2.5, 55 + valueColor: AlwaysStoppedAnimation<Color>(Colors.white), 56 + ), 57 + ), 58 + const SizedBox(width: 12), 59 + Text( 60 + 'Uploading photos...', 61 + style: theme.textTheme.titleMedium?.copyWith(color: Colors.white), 62 + ), 63 + ], 64 + ), 65 + const SizedBox(height: 16), 66 + 67 + // Show current image at true aspect ratio 68 + if (currentImage != null) 69 + Container( 70 + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300), 71 + child: Image.file( 72 + File(currentImage.file.path), 73 + fit: BoxFit.contain, // Maintain aspect ratio 74 + ), 75 + ), 76 + 77 + const SizedBox(height: 16), 78 + 79 + // Progress indicator (overall progress) 80 + SizedBox( 81 + width: 300, 82 + child: LinearProgressIndicator( 83 + value: overallProgress, 84 + backgroundColor: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), 85 + valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary), 86 + ), 87 + ), 88 + 89 + const SizedBox(height: 8), 90 + 91 + // Position counter and progress percentage 92 + Text( 93 + '${currentIndex + 1} of ${images.length} • ${(overallProgress * 100).toInt()}%', 94 + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70), 95 + ), 96 + ], 97 + ), 98 + ), 99 + ), 100 + ], 101 + ), 102 + ); 103 + } 104 + }
+2 -2
pubspec.yaml
··· 1 1 name: grain 2 - description: "A new Flutter project." 2 + description: "Grain Social Mobile App" 3 3 # The following line prevents the package from being accidentally published to 4 4 # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 5 publish_to: "none" # Remove this line if you wish to publish to pub.dev ··· 16 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 17 # In Windows, build-name is used as the major, minor, and patch parts 18 18 # of the product and file versions while build-number is used as the build suffix. 19 - version: 1.0.0+18 19 + version: 1.0.0+20 20 20 21 21 environment: 22 22 sdk: ^3.8.1