Grain flutter app

feat: implement URL launching functionality and enhance profile editing UI

+109 -34
+32 -13
lib/providers/profile_provider.dart
··· 42 42 }).toList(); 43 43 } 44 44 45 + // Extract facet computation and filtering for reuse 46 + Future<List<Map<String, dynamic>>?> computeAndFilterFacets(String? description) async { 47 + final desc = description ?? ''; 48 + if (desc.isEmpty) return null; 49 + try { 50 + final blueskyText = BlueskyText(desc); 51 + final entities = blueskyText.entities; 52 + final computedFacets = await entities.toFacets(); 53 + return _filterValidFacets(computedFacets, desc); 54 + } catch (_) { 55 + return null; 56 + } 57 + } 58 + 45 59 Future<ProfileWithGalleries?> _fetchProfile(String did) async { 46 60 final profile = await apiService.fetchProfile(did: did); 47 61 final galleries = await apiService.fetchActorGalleries(did: did); 48 62 if (profile != null) { 49 - List<Map<String, dynamic>>? facets; 50 - final desc = profile.description ?? ''; 51 - if (desc.isNotEmpty) { 52 - try { 53 - final blueskyText = BlueskyText(desc); 54 - final entities = blueskyText.entities; 55 - final computedFacets = await entities.toFacets(); 56 - facets = _filterValidFacets(computedFacets, desc); 57 - } catch (_) { 58 - facets = null; 59 - } 60 - } 63 + final facets = await computeAndFilterFacets(profile.description); 61 64 return ProfileWithGalleries( 62 65 profile: profile.copyWith(descriptionFacets: facets), 63 66 galleries: galleries, ··· 76 79 required String description, 77 80 dynamic avatarFile, 78 81 }) async { 82 + final currentProfile = state.value?.profile; 83 + final isUnchanged = 84 + currentProfile != null && 85 + currentProfile.displayName == displayName && 86 + currentProfile.description == description && 87 + avatarFile == null; 88 + if (isUnchanged) { 89 + // No changes, skip API call 90 + return true; 91 + } 79 92 File? file; 80 93 if (avatarFile is XFile) { 81 94 file = File(avatarFile.path); ··· 106 119 // Always assign a new instance to state 107 120 if (updated != null) { 108 121 final galleries = await apiService.fetchActorGalleries(did: did); 122 + final facets = await computeAndFilterFacets(updated.description); 109 123 // Update the gallery cache provider 110 124 ref.read(galleryCacheProvider.notifier).setGalleriesForActor(did, galleries); 111 - state = AsyncValue.data(ProfileWithGalleries(profile: updated, galleries: galleries)); 125 + state = AsyncValue.data( 126 + ProfileWithGalleries( 127 + profile: updated.copyWith(descriptionFacets: facets), 128 + galleries: galleries, 129 + ), 130 + ); 112 131 } else { 113 132 state = const AsyncValue.data(null); 114 133 }
+16 -7
lib/screens/comments_page.dart
··· 11 11 import 'package:grain/widgets/app_image.dart'; 12 12 import 'package:grain/widgets/faceted_text.dart'; 13 13 import 'package:grain/widgets/gallery_photo_view.dart'; 14 + import 'package:url_launcher/url_launcher.dart'; 14 15 15 16 class CommentsPage extends ConsumerStatefulWidget { 16 17 final String galleryUri; ··· 102 103 ) 103 104 : RefreshIndicator( 104 105 onRefresh: () async { 105 - await ref.read(galleryThreadProvider(widget.galleryUri).notifier).fetchThread(); 106 + await ref 107 + .read(galleryThreadProvider(widget.galleryUri).notifier) 108 + .fetchThread(); 106 109 }, 107 110 child: ListView( 108 111 padding: const EdgeInsets.fromLTRB(12, 12, 12, 100), 109 112 children: [ 110 113 if (threadState.gallery != null) 111 - Text(threadState.gallery!.title ?? '', style: theme.textTheme.titleMedium), 114 + Text( 115 + threadState.gallery!.title ?? '', 116 + style: theme.textTheme.titleMedium, 117 + ), 112 118 const SizedBox(height: 12), 113 119 _CommentsList( 114 120 comments: threadState.comments, ··· 126 132 context: context, 127 133 builder: (ctx) => AlertDialog( 128 134 title: const Text('Delete Comment'), 129 - content: const Text('Are you sure you want to delete this comment?'), 135 + content: const Text( 136 + 'Are you sure you want to delete this comment?', 137 + ), 130 138 actions: [ 131 139 TextButton( 132 140 onPressed: () => Navigator.of(ctx).pop(false), ··· 455 463 context, 456 464 ).push(MaterialPageRoute(builder: (context) => ProfilePage(did: did))); 457 465 }, 458 - onLinkTap: (url) { 459 - // Navigator.of( 460 - // context, 461 - // ).push(MaterialPageRoute(builder: (context) => WebViewPage(url: url))); 466 + onLinkTap: (url) async { 467 + final uri = Uri.parse(url); 468 + if (!await launchUrl(uri)) { 469 + throw Exception('Could not launch $url'); 470 + } 462 471 }, 463 472 onTagTap: (tag) => Navigator.push( 464 473 context,
+6 -2
lib/screens/gallery_page.dart
··· 11 11 import 'package:grain/widgets/gallery_action_buttons.dart'; 12 12 import 'package:grain/widgets/gallery_photo_view.dart'; 13 13 import 'package:grain/widgets/justified_gallery_view.dart'; 14 + import 'package:url_launcher/url_launcher.dart'; 14 15 15 16 class GalleryPage extends ConsumerStatefulWidget { 16 17 final String uri; ··· 241 242 ), 242 243 ); 243 244 }, 244 - onLinkTap: (url) { 245 - // TODO: Implement or use your WebViewPage 245 + onLinkTap: (url) async { 246 + final uri = Uri.parse(url); 247 + if (!await launchUrl(uri)) { 248 + throw Exception('Could not launch $url'); 249 + } 246 250 }, 247 251 onTagTap: (tag) => Navigator.push( 248 252 context,
+6 -2
lib/screens/profile_page.dart
··· 10 10 import 'package:grain/widgets/app_image.dart'; 11 11 import 'package:grain/widgets/edit_profile_sheet.dart'; 12 12 import 'package:grain/widgets/faceted_text.dart'; 13 + import 'package:url_launcher/url_launcher.dart'; 13 14 14 15 import 'gallery_page.dart'; 15 16 ··· 297 298 ), 298 299 ); 299 300 }, 300 - onLinkTap: (url) { 301 - // TODO: Implement WebViewPage navigation 301 + onLinkTap: (url) async { 302 + final uri = Uri.parse(url); 303 + if (!await launchUrl(uri)) { 304 + throw Exception('Could not launch $url'); 305 + } 302 306 }, 303 307 onTagTap: (tag) => Navigator.push( 304 308 context,
+33 -3
lib/widgets/app_button.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 - enum AppButtonVariant { primary, secondary } 3 + enum AppButtonVariant { primary, secondary, text } 4 4 5 5 enum AppButtonSize { normal, small } 6 6 ··· 8 8 final String label; 9 9 final VoidCallback? onPressed; 10 10 final bool loading; 11 + final bool disabled; 11 12 final AppButtonVariant variant; 12 13 final AppButtonSize size; 13 14 final IconData? icon; ··· 21 22 required this.label, 22 23 this.onPressed, 23 24 this.loading = false, 25 + this.disabled = false, 24 26 this.variant = AppButtonVariant.primary, 25 27 this.size = AppButtonSize.normal, 26 28 this.icon, ··· 39 41 final Color secondaryText = theme.colorScheme.onSurface; 40 42 final Color primaryText = theme.colorScheme.onPrimary; 41 43 final bool isPrimary = variant == AppButtonVariant.primary; 44 + final bool isText = variant == AppButtonVariant.text; 42 45 43 46 final double resolvedHeight = size == AppButtonSize.small ? 32 : height; 44 47 final double resolvedFontSize = size == AppButtonSize.small ? 14 : fontSize; ··· 49 52 ? const EdgeInsets.symmetric(horizontal: 14, vertical: 0) 50 53 : const EdgeInsets.symmetric(horizontal: 16)); 51 54 55 + if (isText) { 56 + return SizedBox( 57 + height: resolvedHeight, 58 + child: TextButton( 59 + onPressed: (loading || disabled) ? null : onPressed, 60 + style: TextButton.styleFrom( 61 + padding: resolvedPadding, 62 + foregroundColor: disabled ? secondaryText.withOpacity(0.5) : secondaryText, 63 + textStyle: theme.textTheme.labelLarge?.copyWith( 64 + fontWeight: FontWeight.w600, 65 + fontSize: resolvedFontSize, 66 + ), 67 + ), 68 + child: Text( 69 + label, 70 + style: theme.textTheme.labelLarge?.copyWith( 71 + color: disabled ? primaryColor.withOpacity(0.5) : primaryColor, 72 + fontWeight: FontWeight.w600, 73 + fontSize: resolvedFontSize, 74 + ), 75 + ), 76 + ), 77 + ); 78 + } 79 + 52 80 return SizedBox( 53 81 height: resolvedHeight, 54 82 child: ElevatedButton( 55 - onPressed: loading ? null : onPressed, 83 + onPressed: (loading || disabled) ? null : onPressed, 56 84 style: ElevatedButton.styleFrom( 57 - backgroundColor: isPrimary ? primaryColor : secondaryColor, 85 + backgroundColor: isPrimary 86 + ? (disabled ? primaryColor.withOpacity(0.5) : primaryColor) 87 + : (disabled ? secondaryColor.withOpacity(0.5) : secondaryColor), 58 88 foregroundColor: isPrimary ? primaryText : secondaryText, 59 89 elevation: 0, 60 90 shape: RoundedRectangleBorder(
+9 -4
lib/widgets/edit_profile_sheet.dart
··· 1 1 import 'dart:io'; 2 2 3 3 import 'package:flutter/material.dart'; 4 + import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 5 import 'package:grain/widgets/app_button.dart'; 5 6 import 'package:grain/widgets/plain_text_field.dart'; 6 7 import 'package:image_picker/image_picker.dart'; ··· 94 95 AppButton( 95 96 label: 'Cancel', 96 97 size: AppButtonSize.small, 97 - variant: AppButtonVariant.secondary, 98 + variant: AppButtonVariant.text, 99 + disabled: _saving, 98 100 onPressed: widget.onCancel ?? () => Navigator.of(context).maybePop(), 99 101 ), 100 102 Text( ··· 103 105 ), 104 106 AppButton( 105 107 label: 'Save', 106 - size: AppButtonSize.small, 107 108 variant: AppButtonVariant.primary, 108 109 loading: _saving, 109 110 onPressed: _saving ? null : _onSave, 111 + height: 36, 112 + fontSize: 15, 113 + borderRadius: 22, 114 + padding: const EdgeInsets.symmetric(horizontal: 18), 110 115 ), 111 116 ], 112 117 ), ··· 145 150 shape: BoxShape.circle, 146 151 ), 147 152 padding: const EdgeInsets.all(6), 148 - child: Icon(Icons.edit, color: Colors.white, size: 18), 153 + child: Icon(FontAwesomeIcons.camera, color: Colors.white, size: 12), 149 154 ), 150 155 ), 151 156 ], ··· 166 171 PlainTextField( 167 172 label: 'Description', 168 173 controller: _descriptionController, 169 - maxLines: 3, 174 + maxLines: 6, 170 175 ), 171 176 ], 172 177 ),
+1 -1
pubspec.lock
··· 1190 1190 source: hosted 1191 1191 version: "1.4.0" 1192 1192 url_launcher: 1193 - dependency: transitive 1193 + dependency: "direct main" 1194 1194 description: 1195 1195 name: url_launcher 1196 1196 sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
+1
pubspec.yaml
··· 58 58 riverpod_annotation: ^2.6.1 59 59 freezed_annotation: ^2.4.4 60 60 json_annotation: ^4.9.0 61 + url_launcher: ^6.3.1 61 62 62 63 dependency_overrides: 63 64 analyzer: 7.3.0