this repo has no description

feat: Update profile following logic and UI enhancements

- Adjusted the profile following logic to correctly update followers count on follow/unfollow actions.
- Refactored CommentsPage to use Riverpod for state management, improving loading and error handling.
- Introduced CommentInputSheet for better comment submission UX.
- Enhanced CreateGalleryPage UI with improved layout and button styles.
- Updated GalleryPage to support pull-to-refresh functionality and improved layout.
- Modified ProfilePage to use a smaller button size for follow/unfollow actions.
- Added AppButtonSize enum to manage button sizes consistently across the app.
- Improved PlainTextField styling for better focus indication and usability.

+870 -593
+1 -1
lib/api.dart
··· 146 appLogger.i('Fetching gallery thread for uri: $uri'); 147 final response = await http.get( 148 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'), 149 - headers: {'Content-Type': 'application/json'}, 150 ); 151 if (response.statusCode != 200) { 152 appLogger.w('Failed to fetch gallery thread: \\${response.statusCode} \\${response.body}');
··· 146 appLogger.i('Fetching gallery thread for uri: $uri'); 147 final response = await http.get( 148 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'), 149 + headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $_accessToken"}, 150 ); 151 if (response.statusCode != 200) { 152 appLogger.w('Failed to fetch gallery thread: \\${response.statusCode} \\${response.body}');
+120
lib/providers/gallery_thread_cache_provider.dart
···
··· 1 + import 'package:bluesky_text/bluesky_text.dart'; 2 + import 'package:grain/api.dart'; 3 + import 'package:grain/models/comment.dart'; 4 + import 'package:grain/models/gallery.dart'; 5 + import 'package:grain/providers/gallery_cache_provider.dart'; 6 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 7 + 8 + part 'gallery_thread_cache_provider.g.dart'; 9 + 10 + class GalleryThreadState { 11 + final bool loading; 12 + final bool error; 13 + final Gallery? gallery; 14 + final List<Comment> comments; 15 + final String? errorMessage; 16 + const GalleryThreadState({ 17 + this.loading = false, 18 + this.error = false, 19 + this.gallery, 20 + this.comments = const [], 21 + this.errorMessage, 22 + }); 23 + 24 + GalleryThreadState copyWith({ 25 + bool? loading, 26 + bool? error, 27 + Gallery? gallery, 28 + List<Comment>? comments, 29 + String? errorMessage, 30 + }) { 31 + return GalleryThreadState( 32 + loading: loading ?? this.loading, 33 + error: error ?? this.error, 34 + gallery: gallery ?? this.gallery, 35 + comments: comments ?? this.comments, 36 + errorMessage: errorMessage ?? this.errorMessage, 37 + ); 38 + } 39 + } 40 + 41 + @Riverpod(keepAlive: false) 42 + class GalleryThread extends _$GalleryThread { 43 + late String galleryUri; 44 + 45 + @override 46 + GalleryThreadState build(String galleryUriParam) { 47 + galleryUri = galleryUriParam; 48 + // Set initial state synchronously, schedule fetchThread async 49 + Future.microtask(fetchThread); 50 + return const GalleryThreadState(loading: true); 51 + } 52 + 53 + Future<void> fetchThread() async { 54 + state = state.copyWith(loading: true, error: false); 55 + try { 56 + final thread = await apiService.getGalleryThread(uri: galleryUri); 57 + state = state.copyWith( 58 + gallery: thread?.gallery, 59 + comments: thread?.comments ?? [], 60 + loading: false, 61 + error: false, 62 + ); 63 + } catch (e) { 64 + state = state.copyWith(loading: false, error: true, errorMessage: e.toString()); 65 + } 66 + } 67 + 68 + Future<List<Map<String, dynamic>>> _extractFacets(String text) async { 69 + final blueskyText = BlueskyText(text); 70 + final entities = blueskyText.entities; 71 + final facets = await entities.toFacets(); 72 + return List<Map<String, dynamic>>.from(facets); 73 + } 74 + 75 + Future<bool> createComment({required String text, String? replyTo}) async { 76 + try { 77 + final facetsList = await _extractFacets(text); 78 + final facets = facetsList.isEmpty ? null : facetsList; 79 + final uri = await apiService.createComment( 80 + text: text, 81 + subject: galleryUri, 82 + replyTo: replyTo, 83 + facets: facets, 84 + ); 85 + if (uri != null) { 86 + final thread = await apiService.pollGalleryThreadComments( 87 + galleryUri: galleryUri, 88 + expectedCount: state.comments.length + 1, 89 + ); 90 + if (thread != null) { 91 + state = state.copyWith(gallery: thread.gallery, comments: thread.comments); 92 + // Update the gallery cache with the latest gallery 93 + ref.read(galleryCacheProvider.notifier).setGallery(thread.gallery); 94 + } else { 95 + await fetchThread(); 96 + } 97 + return true; 98 + } 99 + } catch (_) {} 100 + return false; 101 + } 102 + 103 + Future<bool> deleteComment(Comment comment) async { 104 + final deleted = await apiService.deleteRecord(comment.uri); 105 + if (!deleted) return false; 106 + final expectedCount = state.comments.length - 1; 107 + final thread = await apiService.pollGalleryThreadComments( 108 + galleryUri: galleryUri, 109 + expectedCount: expectedCount, 110 + ); 111 + if (thread != null) { 112 + state = state.copyWith(gallery: thread.gallery, comments: thread.comments); 113 + // Update the gallery cache with the latest gallery 114 + ref.read(galleryCacheProvider.notifier).setGallery(thread.gallery); 115 + } else { 116 + await fetchThread(); 117 + } 118 + return true; 119 + } 120 + }
+166
lib/providers/gallery_thread_cache_provider.g.dart
···
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'gallery_thread_cache_provider.dart'; 4 + 5 + // ************************************************************************** 6 + // RiverpodGenerator 7 + // ************************************************************************** 8 + 9 + String _$galleryThreadHash() => r'c96bf466ccaf8e4856bbc33720d39e68a6405742'; 10 + 11 + /// Copied from Dart SDK 12 + class _SystemHash { 13 + _SystemHash._(); 14 + 15 + static int combine(int hash, int value) { 16 + // ignore: parameter_assignments 17 + hash = 0x1fffffff & (hash + value); 18 + // ignore: parameter_assignments 19 + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); 20 + return hash ^ (hash >> 6); 21 + } 22 + 23 + static int finish(int hash) { 24 + // ignore: parameter_assignments 25 + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); 26 + // ignore: parameter_assignments 27 + hash = hash ^ (hash >> 11); 28 + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); 29 + } 30 + } 31 + 32 + abstract class _$GalleryThread 33 + extends BuildlessAutoDisposeNotifier<GalleryThreadState> { 34 + late final String galleryUriParam; 35 + 36 + GalleryThreadState build(String galleryUriParam); 37 + } 38 + 39 + /// See also [GalleryThread]. 40 + @ProviderFor(GalleryThread) 41 + const galleryThreadProvider = GalleryThreadFamily(); 42 + 43 + /// See also [GalleryThread]. 44 + class GalleryThreadFamily extends Family<GalleryThreadState> { 45 + /// See also [GalleryThread]. 46 + const GalleryThreadFamily(); 47 + 48 + /// See also [GalleryThread]. 49 + GalleryThreadProvider call(String galleryUriParam) { 50 + return GalleryThreadProvider(galleryUriParam); 51 + } 52 + 53 + @override 54 + GalleryThreadProvider getProviderOverride( 55 + covariant GalleryThreadProvider provider, 56 + ) { 57 + return call(provider.galleryUriParam); 58 + } 59 + 60 + static const Iterable<ProviderOrFamily>? _dependencies = null; 61 + 62 + @override 63 + Iterable<ProviderOrFamily>? get dependencies => _dependencies; 64 + 65 + static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; 66 + 67 + @override 68 + Iterable<ProviderOrFamily>? get allTransitiveDependencies => 69 + _allTransitiveDependencies; 70 + 71 + @override 72 + String? get name => r'galleryThreadProvider'; 73 + } 74 + 75 + /// See also [GalleryThread]. 76 + class GalleryThreadProvider 77 + extends AutoDisposeNotifierProviderImpl<GalleryThread, GalleryThreadState> { 78 + /// See also [GalleryThread]. 79 + GalleryThreadProvider(String galleryUriParam) 80 + : this._internal( 81 + () => GalleryThread()..galleryUriParam = galleryUriParam, 82 + from: galleryThreadProvider, 83 + name: r'galleryThreadProvider', 84 + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') 85 + ? null 86 + : _$galleryThreadHash, 87 + dependencies: GalleryThreadFamily._dependencies, 88 + allTransitiveDependencies: 89 + GalleryThreadFamily._allTransitiveDependencies, 90 + galleryUriParam: galleryUriParam, 91 + ); 92 + 93 + GalleryThreadProvider._internal( 94 + super._createNotifier, { 95 + required super.name, 96 + required super.dependencies, 97 + required super.allTransitiveDependencies, 98 + required super.debugGetCreateSourceHash, 99 + required super.from, 100 + required this.galleryUriParam, 101 + }) : super.internal(); 102 + 103 + final String galleryUriParam; 104 + 105 + @override 106 + GalleryThreadState runNotifierBuild(covariant GalleryThread notifier) { 107 + return notifier.build(galleryUriParam); 108 + } 109 + 110 + @override 111 + Override overrideWith(GalleryThread Function() create) { 112 + return ProviderOverride( 113 + origin: this, 114 + override: GalleryThreadProvider._internal( 115 + () => create()..galleryUriParam = galleryUriParam, 116 + from: from, 117 + name: null, 118 + dependencies: null, 119 + allTransitiveDependencies: null, 120 + debugGetCreateSourceHash: null, 121 + galleryUriParam: galleryUriParam, 122 + ), 123 + ); 124 + } 125 + 126 + @override 127 + AutoDisposeNotifierProviderElement<GalleryThread, GalleryThreadState> 128 + createElement() { 129 + return _GalleryThreadProviderElement(this); 130 + } 131 + 132 + @override 133 + bool operator ==(Object other) { 134 + return other is GalleryThreadProvider && 135 + other.galleryUriParam == galleryUriParam; 136 + } 137 + 138 + @override 139 + int get hashCode { 140 + var hash = _SystemHash.combine(0, runtimeType.hashCode); 141 + hash = _SystemHash.combine(hash, galleryUriParam.hashCode); 142 + 143 + return _SystemHash.finish(hash); 144 + } 145 + } 146 + 147 + @Deprecated('Will be removed in 3.0. Use Ref instead') 148 + // ignore: unused_element 149 + mixin GalleryThreadRef on AutoDisposeNotifierProviderRef<GalleryThreadState> { 150 + /// The parameter `galleryUriParam` of this provider. 151 + String get galleryUriParam; 152 + } 153 + 154 + class _GalleryThreadProviderElement 155 + extends 156 + AutoDisposeNotifierProviderElement<GalleryThread, GalleryThreadState> 157 + with GalleryThreadRef { 158 + _GalleryThreadProviderElement(super.provider); 159 + 160 + @override 161 + String get galleryUriParam => 162 + (origin as GalleryThreadProvider).galleryUriParam; 163 + } 164 + 165 + // ignore_for_file: type=lint 166 + // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
+8 -2
lib/providers/profile_cache_provider.dart
··· 33 // Unfollow 34 final success = await apiService.deleteRecord(followUri); 35 if (success) { 36 - final updatedProfile = profile.copyWith(viewer: viewer?.copyWith(following: null)); 37 state = {...state, followeeDid: updatedProfile}; 38 } 39 } else { 40 // Follow 41 final newFollowUri = await apiService.createFollow(followeeDid: followeeDid); 42 if (newFollowUri != null) { 43 - final updatedProfile = profile.copyWith(viewer: viewer?.copyWith(following: newFollowUri)); 44 state = {...state, followeeDid: updatedProfile}; 45 } 46 }
··· 33 // Unfollow 34 final success = await apiService.deleteRecord(followUri); 35 if (success) { 36 + final updatedProfile = profile.copyWith( 37 + viewer: viewer?.copyWith(following: null), 38 + followersCount: (profile.followersCount ?? 1) - 1, 39 + ); 40 state = {...state, followeeDid: updatedProfile}; 41 } 42 } else { 43 // Follow 44 final newFollowUri = await apiService.createFollow(followeeDid: followeeDid); 45 if (newFollowUri != null) { 46 + final updatedProfile = profile.copyWith( 47 + viewer: viewer?.copyWith(following: newFollowUri), 48 + followersCount: (profile.followersCount ?? 0) + 1, 49 + ); 50 state = {...state, followeeDid: updatedProfile}; 51 } 52 }
+1 -1
lib/providers/profile_cache_provider.g.dart
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 - String _$profileCacheHash() => r'd3110401e19ec458d25f10f955ba245d9d775436'; 10 11 /// See also [ProfileCache]. 12 @ProviderFor(ProfileCache)
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 + String _$profileCacheHash() => r'e2b88310e5f54587442c2a9a0307b01030993fcc'; 10 11 /// See also [ProfileCache]. 12 @ProviderFor(ProfileCache)
+244 -289
lib/screens/comments_page.dart
··· 1 - import 'package:bluesky_text/bluesky_text.dart'; 2 import 'package:flutter/material.dart'; 3 import 'package:grain/api.dart'; 4 import 'package:grain/models/comment.dart'; 5 - import 'package:grain/models/gallery.dart'; 6 import 'package:grain/models/gallery_photo.dart'; 7 import 'package:grain/screens/hashtag_page.dart'; 8 import 'package:grain/screens/profile_page.dart'; 9 import 'package:grain/utils.dart'; 10 import 'package:grain/widgets/app_image.dart'; 11 import 'package:grain/widgets/faceted_text.dart'; 12 import 'package:grain/widgets/gallery_photo_view.dart'; 13 14 - class CommentsPage extends StatefulWidget { 15 final String galleryUri; 16 const CommentsPage({super.key, required this.galleryUri}); 17 18 @override 19 - State<CommentsPage> createState() => _CommentsPageState(); 20 } 21 22 - class _CommentsPageState extends State<CommentsPage> { 23 - bool _loading = true; 24 - bool _error = false; 25 - Gallery? _gallery; 26 - List<Comment> _comments = []; 27 GalleryPhoto? _selectedPhoto; 28 - bool _showInputBar = false; 29 - final TextEditingController _replyController = TextEditingController(); 30 - final FocusNode _replyFocusNode = FocusNode(); 31 - String? _replyTo; 32 33 - @override 34 - void initState() { 35 - super.initState(); 36 - _fetchThread(); 37 - } 38 - 39 - @override 40 - void dispose() { 41 - _replyController.dispose(); 42 - _replyFocusNode.dispose(); 43 - super.dispose(); 44 - } 45 - 46 - Future<void> _fetchThread() async { 47 - setState(() { 48 - _loading = true; 49 - _error = false; 50 - }); 51 - try { 52 - final thread = await apiService.getGalleryThread(uri: widget.galleryUri); 53 - setState(() { 54 - _gallery = thread?.gallery; 55 - _comments = thread?.comments ?? []; 56 - _loading = false; 57 - }); 58 - } catch (e) { 59 - setState(() { 60 - _error = true; 61 - _loading = false; 62 - }); 63 - } 64 - } 65 - 66 - void _showReplyBar({String? replyTo, String? mention}) { 67 - setState(() { 68 - _showInputBar = true; 69 - _replyTo = replyTo; 70 - }); 71 - if (mention != null && mention.isNotEmpty) { 72 - _replyController.text = mention; 73 - _replyController.selection = TextSelection.fromPosition( 74 - TextPosition(offset: _replyController.text.length), 75 - ); 76 - } else { 77 - _replyController.clear(); 78 - } 79 - Future.delayed(const Duration(milliseconds: 100), () { 80 - if (mounted) FocusScope.of(context).requestFocus(_replyFocusNode); 81 - }); 82 - } 83 - 84 - void _hideReplyBar() { 85 - setState(() { 86 - _showInputBar = false; 87 - _replyController.clear(); 88 - _replyTo = null; 89 - }); 90 - FocusScope.of(context).unfocus(); 91 - } 92 - 93 - Future<void> handleDeleteComment(Comment comment) async { 94 - final confirmed = await showDialog<bool>( 95 context: context, 96 - builder: (ctx) => AlertDialog( 97 - title: const Text('Delete Comment'), 98 - content: const Text('Are you sure you want to delete this comment?'), 99 - actions: [ 100 - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')), 101 - TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete')), 102 - ], 103 ), 104 ); 105 - if (confirmed != true) return; 106 - final scaffold = ScaffoldMessenger.of(context); 107 - scaffold.removeCurrentSnackBar(); 108 - scaffold.showSnackBar(const SnackBar(content: Text('Deleting comment...'))); 109 - final deleted = await apiService.deleteRecord(comment.uri); 110 - if (!deleted) { 111 - scaffold.removeCurrentSnackBar(); 112 - scaffold.showSnackBar(const SnackBar(content: Text('Failed to delete comment.'))); 113 - return; 114 - } 115 - final expectedCount = _comments.length - 1; 116 - final thread = await apiService.pollGalleryThreadComments( 117 - galleryUri: widget.galleryUri, 118 - expectedCount: expectedCount, 119 - ); 120 - if (thread != null) { 121 - setState(() { 122 - _gallery = thread.gallery; 123 - _comments = thread.comments; 124 - }); 125 - } else { 126 - await _fetchThread(); 127 - } 128 - scaffold.removeCurrentSnackBar(); 129 - scaffold.showSnackBar(const SnackBar(content: Text('Comment deleted.'))); 130 - } 131 - 132 - // Extract facets using the async BlueskyText/entities/toFacets pattern 133 - Future<List<Map<String, dynamic>>> _extractFacets(String text) async { 134 - final blueskyText = BlueskyText(text); 135 - final entities = blueskyText.entities; 136 - final facets = await entities.toFacets(); 137 - return List<Map<String, dynamic>>.from(facets); 138 } 139 140 @override 141 Widget build(BuildContext context) { 142 final theme = Theme.of(context); 143 return Stack( 144 children: [ ··· 155 ), 156 body: GestureDetector( 157 behavior: HitTestBehavior.translucent, 158 - onTap: () { 159 - if (_showInputBar) _hideReplyBar(); 160 - }, 161 - child: _loading 162 ? Center( 163 child: CircularProgressIndicator( 164 strokeWidth: 2, 165 color: theme.colorScheme.primary, 166 ), 167 ) 168 - : _error 169 - ? Center(child: Text('Failed to load comments.', style: theme.textTheme.bodyMedium)) 170 - : ListView( 171 - padding: const EdgeInsets.fromLTRB(12, 12, 12, 100), 172 - children: [ 173 - if (_gallery != null) 174 - Text(_gallery!.title ?? '', style: theme.textTheme.titleMedium), 175 - const SizedBox(height: 12), 176 - _CommentsList( 177 - comments: _comments, 178 - onPhotoTap: (photo) { 179 - setState(() { 180 - _selectedPhoto = photo; 181 - }); 182 - }, 183 - onReply: (replyTo, {mention}) => 184 - _showReplyBar(replyTo: replyTo, mention: mention), 185 - onDelete: handleDeleteComment, 186 - ), 187 - ], 188 - ), 189 - ), 190 - bottomNavigationBar: _showInputBar 191 - ? AnimatedPadding( 192 - duration: const Duration(milliseconds: 150), 193 - curve: Curves.easeOut, 194 - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), 195 - child: Builder( 196 - builder: (context) { 197 - final keyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; 198 - return Padding( 199 - padding: EdgeInsets.only(bottom: keyboardOpen ? 0 : 12), 200 - child: Column( 201 - mainAxisSize: MainAxisSize.min, 202 - children: [ 203 - Divider(height: 1, thickness: 1, color: theme.dividerColor), 204 - Container( 205 - color: theme.colorScheme.surfaceContainer, 206 - child: Row( 207 - crossAxisAlignment: CrossAxisAlignment.end, 208 - children: [ 209 - Expanded( 210 - child: Container( 211 - decoration: BoxDecoration( 212 - color: theme.colorScheme.surfaceContainerHighest 213 - .withOpacity(0.95), 214 - borderRadius: BorderRadius.circular(18), 215 - ), 216 - padding: const EdgeInsets.symmetric( 217 - horizontal: 18, 218 - vertical: 12, 219 - ), 220 - child: ConstrainedBox( 221 - constraints: const BoxConstraints( 222 - minHeight: 40, 223 - maxHeight: 120, 224 - ), 225 - child: Scrollbar( 226 - child: TextField( 227 - controller: _replyController, 228 - focusNode: _replyFocusNode, 229 - autofocus: true, 230 - minLines: 1, 231 - maxLines: 5, 232 - textInputAction: TextInputAction.newline, 233 - decoration: const InputDecoration( 234 - hintText: 'Write a reply...', 235 - border: InputBorder.none, 236 - isCollapsed: true, 237 - ), 238 - style: theme.textTheme.bodyLarge, 239 - onSubmitted: (value) async { 240 - if (value.trim().isEmpty) return; 241 - final text = value.trim(); 242 - final facets = await _extractFacets(text); 243 - final uri = await apiService.createComment( 244 - text: text, 245 - subject: widget.galleryUri, 246 - replyTo: _replyTo, 247 - facets: facets, 248 - ); 249 - if (uri != null) { 250 - final thread = await apiService 251 - .pollGalleryThreadComments( 252 - galleryUri: widget.galleryUri, 253 - expectedCount: _comments.length + 1, 254 - ); 255 - if (thread != null) { 256 - setState(() { 257 - _gallery = thread.gallery; 258 - _comments = thread.comments; 259 - }); 260 - } else { 261 - await _fetchThread(); 262 - } 263 - } 264 - _hideReplyBar(); 265 - }, 266 - ), 267 - ), 268 - ), 269 - ), 270 ), 271 - const SizedBox(width: 8), 272 - Container( 273 - margin: const EdgeInsets.only(right: 10, bottom: 8), 274 - decoration: BoxDecoration( 275 - color: theme.colorScheme.primary, 276 - borderRadius: BorderRadius.circular(16), 277 - ), 278 - child: IconButton( 279 - icon: Icon(Icons.send, color: theme.colorScheme.onPrimary), 280 - onPressed: () async { 281 - final value = _replyController.text.trim(); 282 - if (value.isEmpty) return; 283 - final facets = await _extractFacets(value); 284 - final uri = await apiService.createComment( 285 - text: value, 286 - subject: widget.galleryUri, 287 - replyTo: _replyTo, 288 - facets: facets, 289 - ); 290 - if (uri != null) { 291 - final thread = await apiService.pollGalleryThreadComments( 292 - galleryUri: widget.galleryUri, 293 - expectedCount: _comments.length + 1, 294 - ); 295 - if (thread != null) { 296 - setState(() { 297 - _gallery = thread.gallery; 298 - _comments = thread.comments; 299 - }); 300 - } else { 301 - await _fetchThread(); 302 - } 303 - } 304 - _hideReplyBar(); 305 - }, 306 - ), 307 ), 308 ], 309 ), 310 - ), 311 - ], 312 ), 313 - ); 314 - }, 315 ), 316 - ) 317 - : Container( 318 - color: theme.colorScheme.surface, 319 - child: SafeArea( 320 - child: GestureDetector( 321 - onTap: _showReplyBar, 322 - child: Container( 323 - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), 324 - child: Container( 325 - height: 44, 326 - decoration: BoxDecoration( 327 - color: theme.colorScheme.surfaceContainerHighest, 328 - borderRadius: BorderRadius.circular(22), 329 - ), 330 - child: Row( 331 - children: [ 332 - Icon(Icons.reply, color: theme.iconTheme.color, size: 20), 333 - const SizedBox(width: 8), 334 - Text( 335 - 'Add a reply...', 336 - style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 337 - ), 338 - ], 339 - ), 340 ), 341 - ), 342 ), 343 ), 344 ), 345 - // Show photo view overlay if needed 346 extendBody: true, 347 extendBodyBehindAppBar: false, 348 ), ··· 355 ), 356 ), 357 ], 358 ); 359 } 360 } ··· 583 ), 584 ), 585 ), 586 - if (comment.author['did'] == (apiService.currentUser?.did ?? '')) ...[ 587 const SizedBox(width: 16), 588 TextButton( 589 style: TextButton.styleFrom( 590 padding: EdgeInsets.zero, ··· 602 ), 603 ), 604 ), 605 - ], 606 ], 607 ), 608 ],
··· 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 import 'package:grain/api.dart'; 4 import 'package:grain/models/comment.dart'; 5 import 'package:grain/models/gallery_photo.dart'; 6 + import 'package:grain/providers/gallery_thread_cache_provider.dart'; 7 import 'package:grain/screens/hashtag_page.dart'; 8 import 'package:grain/screens/profile_page.dart'; 9 import 'package:grain/utils.dart'; 10 + import 'package:grain/widgets/app_button.dart'; 11 import 'package:grain/widgets/app_image.dart'; 12 import 'package:grain/widgets/faceted_text.dart'; 13 import 'package:grain/widgets/gallery_photo_view.dart'; 14 15 + class CommentsPage extends ConsumerStatefulWidget { 16 final String galleryUri; 17 const CommentsPage({super.key, required this.galleryUri}); 18 19 @override 20 + ConsumerState<CommentsPage> createState() => _CommentsPageState(); 21 } 22 23 + class _CommentsPageState extends ConsumerState<CommentsPage> { 24 GalleryPhoto? _selectedPhoto; 25 26 + void _showCommentInputSheet( 27 + BuildContext context, 28 + WidgetRef ref, { 29 + String? replyTo, 30 + String? mention, 31 + }) { 32 + showModalBottomSheet( 33 context: context, 34 + isScrollControlled: true, 35 + builder: (sheetContext) => CommentInputSheet( 36 + initialText: mention ?? '', 37 + onSubmit: (text) async { 38 + await ref 39 + .read(galleryThreadProvider(widget.galleryUri).notifier) 40 + .createComment(text: text.trim(), replyTo: replyTo); 41 + if (!sheetContext.mounted) { 42 + return; 43 + } 44 + Navigator.of(sheetContext).pop(); 45 + }, 46 ), 47 ); 48 } 49 50 @override 51 Widget build(BuildContext context) { 52 + final threadState = ref.watch(galleryThreadProvider(widget.galleryUri)); 53 final theme = Theme.of(context); 54 return Stack( 55 children: [ ··· 66 ), 67 body: GestureDetector( 68 behavior: HitTestBehavior.translucent, 69 + child: threadState.loading 70 ? Center( 71 child: CircularProgressIndicator( 72 strokeWidth: 2, 73 color: theme.colorScheme.primary, 74 ), 75 ) 76 + : threadState.error 77 + ? Center( 78 + child: Column( 79 + mainAxisAlignment: MainAxisAlignment.center, 80 + children: [ 81 + Text('Failed to load comments.', style: theme.textTheme.bodyMedium), 82 + if (threadState.errorMessage != null) 83 + Padding( 84 + padding: const EdgeInsets.only(top: 8.0), 85 + child: Text( 86 + threadState.errorMessage!, 87 + style: theme.textTheme.bodySmall?.copyWith( 88 + color: theme.colorScheme.error, 89 + ), 90 + textAlign: TextAlign.center, 91 + ), 92 + ), 93 + const SizedBox(height: 16), 94 + ElevatedButton( 95 + onPressed: () => ref 96 + .read(galleryThreadProvider(widget.galleryUri).notifier) 97 + .fetchThread(), 98 + child: const Text('Retry'), 99 + ), 100 + ], 101 + ), 102 + ) 103 + : RefreshIndicator( 104 + onRefresh: () async { 105 + await ref.read(galleryThreadProvider(widget.galleryUri).notifier).fetchThread(); 106 + }, 107 + child: ListView( 108 + padding: const EdgeInsets.fromLTRB(12, 12, 12, 100), 109 + children: [ 110 + if (threadState.gallery != null) 111 + Text(threadState.gallery!.title ?? '', style: theme.textTheme.titleMedium), 112 + const SizedBox(height: 12), 113 + _CommentsList( 114 + comments: threadState.comments, 115 + onPhotoTap: (photo) { 116 + setState(() => _selectedPhoto = photo); 117 + }, 118 + onReply: (replyTo, {mention}) => _showCommentInputSheet( 119 + context, 120 + ref, 121 + replyTo: replyTo, 122 + mention: mention, 123 + ), 124 + onDelete: (comment) async { 125 + final confirmed = await showDialog<bool>( 126 + context: context, 127 + builder: (ctx) => AlertDialog( 128 + title: const Text('Delete Comment'), 129 + content: const Text('Are you sure you want to delete this comment?'), 130 + actions: [ 131 + TextButton( 132 + onPressed: () => Navigator.of(ctx).pop(false), 133 + child: const Text('Cancel'), 134 ), 135 + TextButton( 136 + onPressed: () => Navigator.of(ctx).pop(true), 137 + child: const Text('Delete'), 138 ), 139 ], 140 ), 141 + ); 142 + if (confirmed != true) return; 143 + if (!context.mounted) return; 144 + // Inline loading SnackBar 145 + ScaffoldMessenger.of(context).removeCurrentSnackBar(); 146 + ScaffoldMessenger.of(context).showSnackBar( 147 + SnackBar( 148 + content: Row( 149 + children: [ 150 + const SizedBox( 151 + width: 18, 152 + height: 18, 153 + child: CircularProgressIndicator(strokeWidth: 2), 154 + ), 155 + const SizedBox(width: 16), 156 + const Text('Deleting comment...'), 157 + ], 158 + ), 159 + duration: const Duration(minutes: 1), 160 + ), 161 + ); 162 + 163 + final deleted = await ref 164 + .read(galleryThreadProvider(widget.galleryUri).notifier) 165 + .deleteComment(comment); 166 + 167 + if (!context.mounted) return; 168 + ScaffoldMessenger.of(context).removeCurrentSnackBar(); 169 + ScaffoldMessenger.of(context).showSnackBar( 170 + SnackBar( 171 + content: Text( 172 + deleted ? 'Comment deleted.' : 'Failed to delete comment.', 173 + ), 174 + ), 175 + ); 176 + }, 177 ), 178 + ], 179 + ), 180 ), 181 + ), 182 + bottomNavigationBar: Container( 183 + color: theme.colorScheme.surface, 184 + child: SafeArea( 185 + child: GestureDetector( 186 + onTap: () => _showCommentInputSheet(context, ref), 187 + child: Container( 188 + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), 189 + child: Container( 190 + height: 44, 191 + decoration: BoxDecoration( 192 + color: theme.colorScheme.surfaceContainerHighest, 193 + borderRadius: BorderRadius.circular(22), 194 + ), 195 + child: Row( 196 + children: [ 197 + Icon(Icons.reply, color: theme.iconTheme.color, size: 20), 198 + const SizedBox(width: 8), 199 + Text( 200 + 'Add a reply...', 201 + style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 202 ), 203 + ], 204 ), 205 ), 206 ), 207 + ), 208 + ), 209 + ), 210 extendBody: true, 211 extendBodyBehindAppBar: false, 212 ), ··· 219 ), 220 ), 221 ], 222 + ); 223 + } 224 + } 225 + 226 + class CommentInputSheet extends StatefulWidget { 227 + final String initialText; 228 + final Future<void> Function(String text) onSubmit; 229 + const CommentInputSheet({super.key, this.initialText = '', required this.onSubmit}); 230 + 231 + @override 232 + State<CommentInputSheet> createState() => _CommentInputSheetState(); 233 + } 234 + 235 + class _CommentInputSheetState extends State<CommentInputSheet> { 236 + late TextEditingController _controller; 237 + bool _posting = false; 238 + String _currentText = ''; 239 + 240 + @override 241 + void initState() { 242 + super.initState(); 243 + _controller = TextEditingController(text: widget.initialText); 244 + _currentText = widget.initialText; 245 + _controller.addListener(_onTextChanged); 246 + } 247 + 248 + void _onTextChanged() { 249 + if (_currentText != _controller.text) { 250 + setState(() { 251 + _currentText = _controller.text; 252 + }); 253 + } 254 + } 255 + 256 + @override 257 + void dispose() { 258 + _controller.removeListener(_onTextChanged); 259 + _controller.dispose(); 260 + super.dispose(); 261 + } 262 + 263 + @override 264 + Widget build(BuildContext context) { 265 + return Padding( 266 + padding: EdgeInsets.only( 267 + bottom: MediaQuery.of(context).viewInsets.bottom, 268 + left: 16, 269 + right: 16, 270 + top: 16, 271 + ), 272 + child: Column( 273 + mainAxisSize: MainAxisSize.min, 274 + children: [ 275 + Row( 276 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 277 + children: [ 278 + TextButton( 279 + onPressed: _posting ? null : () => Navigator.pop(context), 280 + child: const Text('Cancel'), 281 + ), 282 + AppButton( 283 + borderRadius: 22, 284 + label: 'Post', 285 + loading: _posting, 286 + onPressed: !_posting && _currentText.trim().isNotEmpty 287 + ? () async { 288 + setState(() => _posting = true); 289 + await widget.onSubmit(_currentText.trim()); 290 + } 291 + : null, 292 + ), 293 + ], 294 + ), 295 + const SizedBox(height: 12), 296 + TextField( 297 + controller: _controller, 298 + minLines: 2, 299 + maxLines: 6, 300 + autofocus: true, 301 + decoration: const InputDecoration( 302 + hintText: 'Write your comment...', 303 + border: InputBorder.none, 304 + enabledBorder: InputBorder.none, 305 + focusedBorder: InputBorder.none, 306 + disabledBorder: InputBorder.none, 307 + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), 308 + ), 309 + ), 310 + ], 311 + ), 312 ); 313 } 314 } ··· 537 ), 538 ), 539 ), 540 + if (comment.replyTo == null && 541 + comment.author['did'] == (apiService.currentUser?.did ?? '')) 542 const SizedBox(width: 16), 543 + if (comment.author['did'] == (apiService.currentUser?.did ?? '')) 544 TextButton( 545 style: TextButton.styleFrom( 546 padding: EdgeInsets.zero, ··· 558 ), 559 ), 560 ), 561 ], 562 ), 563 ],
+148 -139
lib/screens/gallery_page.dart
··· 33 _maybeFetchGallery(); 34 } 35 36 - Future<void> _maybeFetchGallery() async { 37 - final cached = ref.read(galleryCacheProvider)[widget.uri]; 38 - if (cached != null) { 39 - setState(() { 40 - _loading = false; 41 - _error = false; 42 - }); 43 - return; 44 } 45 setState(() { 46 _loading = true; ··· 121 ), 122 ], 123 ), 124 - body: ListView( 125 - children: [ 126 - Padding( 127 - padding: const EdgeInsets.symmetric(horizontal: 8), 128 - child: Column( 129 - crossAxisAlignment: CrossAxisAlignment.start, 130 - children: [ 131 - const SizedBox(height: 10), 132 - Text( 133 - gallery.title?.isNotEmpty == true ? gallery.title! : 'Gallery', 134 - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), 135 - ), 136 - const SizedBox(height: 10), 137 - Row( 138 - crossAxisAlignment: CrossAxisAlignment.center, 139 - children: [ 140 - GestureDetector( 141 - onTap: gallery.creator != null && gallery.creator!.did.isNotEmpty 142 - ? () { 143 - Navigator.of(context).push( 144 - MaterialPageRoute( 145 - builder: (context) => 146 - ProfilePage(did: gallery.creator!.did, showAppBar: true), 147 - ), 148 - ); 149 - } 150 - : null, 151 - child: CircleAvatar( 152 - radius: 18, 153 - backgroundColor: theme.colorScheme.surfaceContainerHighest, 154 - backgroundImage: 155 - gallery.creator?.avatar != null && 156 - gallery.creator!.avatar?.isNotEmpty == true 157 - ? null 158 - : null, 159 - child: 160 - (gallery.creator == null || 161 - (gallery.creator!.avatar?.isNotEmpty != true)) 162 - ? Icon( 163 - Icons.account_circle, 164 - size: 24, 165 - color: theme.colorScheme.onSurface.withOpacity(0.4), 166 - ) 167 - : ClipOval( 168 - child: AppImage( 169 - url: gallery.creator!.avatar!, 170 - width: 36, 171 - height: 36, 172 - fit: BoxFit.cover, 173 - ), 174 - ), 175 - ), 176 - ), 177 - const SizedBox(width: 12), 178 - Expanded( 179 - child: GestureDetector( 180 onTap: gallery.creator != null && gallery.creator!.did.isNotEmpty 181 ? () { 182 Navigator.of(context).push( ··· 189 ); 190 } 191 : null, 192 - child: Row( 193 - crossAxisAlignment: CrossAxisAlignment.center, 194 - children: [ 195 - Text( 196 - gallery.creator?.displayName ?? '', 197 - style: theme.textTheme.bodyLarge?.copyWith( 198 - fontWeight: FontWeight.w600, 199 ), 200 - ), 201 - if ((gallery.creator?.displayName ?? '').isNotEmpty && 202 - (gallery.creator?.handle ?? '').isNotEmpty) 203 - const SizedBox(width: 8), 204 - Text( 205 - '@${gallery.creator?.handle ?? ''}', 206 - style: theme.textTheme.bodyMedium?.copyWith( 207 - color: theme.hintColor, 208 ), 209 - ), 210 - ], 211 ), 212 ), 213 - ), 214 - ], 215 - ), 216 - ], 217 ), 218 - ), 219 - const SizedBox(height: 12), 220 - if ((gallery.description?.isNotEmpty ?? false)) 221 - Padding( 222 - padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 8), 223 - child: FacetedText( 224 - text: gallery.description ?? '', 225 - facets: gallery.facets, 226 - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), 227 - linkStyle: theme.textTheme.bodyMedium?.copyWith( 228 - color: theme.colorScheme.primary, 229 - fontWeight: FontWeight.w600, 230 ), 231 - onMentionTap: (did) { 232 - Navigator.of(context).push( 233 - MaterialPageRoute( 234 - builder: (context) => ProfilePage(did: did, showAppBar: true), 235 - ), 236 - ); 237 - }, 238 - onLinkTap: (url) { 239 - // TODO: Implement or use your WebViewPage 240 }, 241 - onTagTap: (tag) => Navigator.push( 242 - context, 243 - MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)), 244 - ), 245 ), 246 - ), 247 - if (isLoggedIn) 248 - Padding( 249 - padding: const EdgeInsets.symmetric(horizontal: 8), 250 - child: GalleryActionButtons( 251 - gallery: gallery, 252 - parentContext: context, 253 - currentUserDid: widget.currentUserDid, 254 - isLoggedIn: isLoggedIn, 255 ), 256 - ), 257 - const SizedBox(height: 8), 258 - // Gallery items grid (edge-to-edge) 259 - if (galleryItems.isNotEmpty) 260 - JustifiedGalleryView( 261 - items: galleryItems, 262 - onImageTap: (index) { 263 - if (index >= 0 && index < galleryItems.length) { 264 - setState(() { 265 - _selectedPhoto = galleryItems[index]; 266 - _selectedPhotoIndex = index; 267 - }); 268 - } 269 - }, 270 - ), 271 - if (galleryItems.isEmpty) 272 - Center( 273 - child: Text('No photos in this gallery.', style: theme.textTheme.bodyMedium), 274 - ), 275 - ], 276 ), 277 ), 278 if (_selectedPhoto != null && _selectedPhotoIndex != null)
··· 33 _maybeFetchGallery(); 34 } 35 36 + Future<void> _maybeFetchGallery({bool forceRefresh = false}) async { 37 + if (!forceRefresh) { 38 + final cached = ref.read(galleryCacheProvider)[widget.uri]; 39 + if (cached != null) { 40 + setState(() { 41 + _loading = false; 42 + _error = false; 43 + }); 44 + return; 45 + } 46 } 47 setState(() { 48 _loading = true; ··· 123 ), 124 ], 125 ), 126 + body: RefreshIndicator( 127 + onRefresh: () => _maybeFetchGallery(forceRefresh: true), 128 + child: ListView( 129 + children: [ 130 + Padding( 131 + padding: const EdgeInsets.symmetric(horizontal: 8), 132 + child: Column( 133 + crossAxisAlignment: CrossAxisAlignment.start, 134 + children: [ 135 + const SizedBox(height: 10), 136 + Text( 137 + gallery.title?.isNotEmpty == true ? gallery.title! : 'Gallery', 138 + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), 139 + ), 140 + const SizedBox(height: 10), 141 + Row( 142 + crossAxisAlignment: CrossAxisAlignment.center, 143 + children: [ 144 + GestureDetector( 145 onTap: gallery.creator != null && gallery.creator!.did.isNotEmpty 146 ? () { 147 Navigator.of(context).push( ··· 154 ); 155 } 156 : null, 157 + child: CircleAvatar( 158 + radius: 18, 159 + backgroundColor: theme.colorScheme.surfaceContainerHighest, 160 + backgroundImage: 161 + gallery.creator?.avatar != null && 162 + gallery.creator!.avatar?.isNotEmpty == true 163 + ? null 164 + : null, 165 + child: 166 + (gallery.creator == null || 167 + (gallery.creator!.avatar?.isNotEmpty != true)) 168 + ? Icon( 169 + Icons.account_circle, 170 + size: 24, 171 + color: theme.colorScheme.onSurface.withOpacity(0.4), 172 + ) 173 + : ClipOval( 174 + child: AppImage( 175 + url: gallery.creator!.avatar!, 176 + width: 36, 177 + height: 36, 178 + fit: BoxFit.cover, 179 + ), 180 + ), 181 + ), 182 + ), 183 + const SizedBox(width: 12), 184 + Expanded( 185 + child: GestureDetector( 186 + onTap: gallery.creator != null && gallery.creator!.did.isNotEmpty 187 + ? () { 188 + Navigator.of(context).push( 189 + MaterialPageRoute( 190 + builder: (context) => ProfilePage( 191 + did: gallery.creator!.did, 192 + showAppBar: true, 193 + ), 194 + ), 195 + ); 196 + } 197 + : null, 198 + child: Row( 199 + crossAxisAlignment: CrossAxisAlignment.center, 200 + children: [ 201 + Text( 202 + gallery.creator?.displayName ?? '', 203 + style: theme.textTheme.bodyLarge?.copyWith( 204 + fontWeight: FontWeight.w600, 205 + ), 206 ), 207 + if ((gallery.creator?.displayName ?? '').isNotEmpty && 208 + (gallery.creator?.handle ?? '').isNotEmpty) 209 + const SizedBox(width: 8), 210 + Text( 211 + '@${gallery.creator?.handle ?? ''}', 212 + style: theme.textTheme.bodyMedium?.copyWith( 213 + color: theme.hintColor, 214 + ), 215 ), 216 + ], 217 + ), 218 ), 219 ), 220 + ], 221 + ), 222 + ], 223 + ), 224 ), 225 + const SizedBox(height: 12), 226 + if ((gallery.description?.isNotEmpty ?? false)) 227 + Padding( 228 + padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 8), 229 + child: FacetedText( 230 + text: gallery.description ?? '', 231 + facets: gallery.facets, 232 + style: theme.textTheme.bodyMedium?.copyWith( 233 + color: theme.colorScheme.onSurface, 234 + ), 235 + linkStyle: theme.textTheme.bodyMedium?.copyWith( 236 + color: theme.colorScheme.primary, 237 + fontWeight: FontWeight.w600, 238 + ), 239 + onMentionTap: (did) { 240 + Navigator.of(context).push( 241 + MaterialPageRoute( 242 + builder: (context) => ProfilePage(did: did, showAppBar: true), 243 + ), 244 + ); 245 + }, 246 + onLinkTap: (url) { 247 + // TODO: Implement or use your WebViewPage 248 + }, 249 + onTagTap: (tag) => Navigator.push( 250 + context, 251 + MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)), 252 + ), 253 + ), 254 + ), 255 + if (isLoggedIn) 256 + Padding( 257 + padding: const EdgeInsets.symmetric(horizontal: 8), 258 + child: GalleryActionButtons( 259 + gallery: gallery, 260 + parentContext: context, 261 + currentUserDid: widget.currentUserDid, 262 + isLoggedIn: isLoggedIn, 263 ), 264 + ), 265 + const SizedBox(height: 8), 266 + // Gallery items grid (edge-to-edge) 267 + if (galleryItems.isNotEmpty) 268 + JustifiedGalleryView( 269 + items: galleryItems, 270 + onImageTap: (index) { 271 + if (index >= 0 && index < galleryItems.length) { 272 + setState(() { 273 + _selectedPhoto = galleryItems[index]; 274 + _selectedPhotoIndex = index; 275 + }); 276 + } 277 }, 278 ), 279 + if (galleryItems.isEmpty) 280 + Center( 281 + child: Text('No photos in this gallery.', style: theme.textTheme.bodyMedium), 282 ), 283 + ], 284 + ), 285 ), 286 ), 287 if (_selectedPhoto != null && _selectedPhotoIndex != null)
+1 -6
lib/screens/profile_page.dart
··· 195 // Follow/Unfollow button 196 if (profile.did != apiService.currentUser?.did) 197 SizedBox( 198 - height: 28, 199 child: AppButton( 200 variant: profile.viewer?.following?.isNotEmpty == true 201 ? AppButtonVariant.secondary 202 : AppButtonVariant.primary, ··· 208 apiService.currentUser?.did, 209 ); 210 }, 211 - borderRadius: 8, 212 - padding: const EdgeInsets.symmetric( 213 - horizontal: 14, 214 - vertical: 0, 215 - ), 216 label: (profile.viewer?.following?.isNotEmpty == true) 217 ? 'Following' 218 : 'Follow',
··· 195 // Follow/Unfollow button 196 if (profile.did != apiService.currentUser?.did) 197 SizedBox( 198 child: AppButton( 199 + size: AppButtonSize.small, 200 variant: profile.viewer?.following?.isNotEmpty == true 201 ? AppButtonVariant.secondary 202 : AppButtonVariant.primary, ··· 208 apiService.currentUser?.did, 209 ); 210 }, 211 label: (profile.viewer?.following?.isNotEmpty == true) 212 ? 'Following' 213 : 'Follow',
+20 -6
lib/widgets/app_button.dart
··· 2 3 enum AppButtonVariant { primary, secondary } 4 5 class AppButton extends StatelessWidget { 6 final String label; 7 final VoidCallback? onPressed; 8 final bool loading; 9 final AppButtonVariant variant; 10 final IconData? icon; 11 final double height; 12 final double borderRadius; ··· 19 this.onPressed, 20 this.loading = false, 21 this.variant = AppButtonVariant.primary, 22 this.icon, 23 this.height = 44, 24 this.borderRadius = 6, ··· 35 final Color secondaryText = theme.colorScheme.onSurface; 36 final Color primaryText = theme.colorScheme.onPrimary; 37 final bool isPrimary = variant == AppButtonVariant.primary; 38 39 return SizedBox( 40 - height: height, 41 child: ElevatedButton( 42 onPressed: loading ? null : onPressed, 43 style: ElevatedButton.styleFrom( ··· 45 foregroundColor: isPrimary ? primaryText : secondaryText, 46 elevation: 0, 47 shape: RoundedRectangleBorder( 48 - borderRadius: BorderRadius.circular(borderRadius), 49 side: isPrimary ? BorderSide.none : BorderSide(color: secondaryBorder, width: 1), 50 ), 51 - padding: padding ?? const EdgeInsets.symmetric(horizontal: 16), 52 textStyle: theme.textTheme.labelLarge?.copyWith( 53 fontWeight: FontWeight.w600, 54 - fontSize: fontSize, 55 ), 56 ), 57 child: loading ··· 74 Text( 75 label, 76 style: theme.textTheme.labelLarge?.copyWith( 77 - fontWeight: FontWeight.w600, 78 - fontSize: fontSize, 79 ), 80 ), 81 ],
··· 2 3 enum AppButtonVariant { primary, secondary } 4 5 + enum AppButtonSize { normal, small } 6 + 7 class AppButton extends StatelessWidget { 8 final String label; 9 final VoidCallback? onPressed; 10 final bool loading; 11 final AppButtonVariant variant; 12 + final AppButtonSize size; 13 final IconData? icon; 14 final double height; 15 final double borderRadius; ··· 22 this.onPressed, 23 this.loading = false, 24 this.variant = AppButtonVariant.primary, 25 + this.size = AppButtonSize.normal, 26 this.icon, 27 this.height = 44, 28 this.borderRadius = 6, ··· 39 final Color secondaryText = theme.colorScheme.onSurface; 40 final Color primaryText = theme.colorScheme.onPrimary; 41 final bool isPrimary = variant == AppButtonVariant.primary; 42 + 43 + final double resolvedHeight = size == AppButtonSize.small ? 32 : height; 44 + final double resolvedFontSize = size == AppButtonSize.small ? 14 : fontSize; 45 + final double resolvedBorderRadius = size == AppButtonSize.small ? 5 : borderRadius; 46 + final EdgeInsetsGeometry resolvedPadding = 47 + padding ?? 48 + (size == AppButtonSize.small 49 + ? const EdgeInsets.symmetric(horizontal: 14, vertical: 0) 50 + : const EdgeInsets.symmetric(horizontal: 16)); 51 52 return SizedBox( 53 + height: resolvedHeight, 54 child: ElevatedButton( 55 onPressed: loading ? null : onPressed, 56 style: ElevatedButton.styleFrom( ··· 58 foregroundColor: isPrimary ? primaryText : secondaryText, 59 elevation: 0, 60 shape: RoundedRectangleBorder( 61 + borderRadius: BorderRadius.circular(resolvedBorderRadius), 62 side: isPrimary ? BorderSide.none : BorderSide(color: secondaryBorder, width: 1), 63 ), 64 + padding: resolvedPadding, 65 textStyle: theme.textTheme.labelLarge?.copyWith( 66 fontWeight: FontWeight.w600, 67 + fontSize: resolvedFontSize, 68 ), 69 ), 70 child: loading ··· 87 Text( 88 label, 89 style: theme.textTheme.labelLarge?.copyWith( 90 + color: isPrimary ? primaryText : secondaryText, 91 + fontWeight: FontWeight.w700, 92 + fontSize: resolvedFontSize, 93 ), 94 ), 95 ],
+33 -23
lib/widgets/plain_text_field.dart
··· 36 const SizedBox(height: 6), 37 Container( 38 decoration: BoxDecoration( 39 - color: theme.colorScheme.surfaceContainerHighest, 40 borderRadius: BorderRadius.circular(8), 41 ), 42 child: Focus( 43 child: Builder( 44 builder: (context) { 45 final isFocused = Focus.of(context).hasFocus; 46 - return AnimatedContainer( 47 - duration: const Duration(milliseconds: 150), 48 - decoration: BoxDecoration( 49 - border: Border.all( 50 - color: isFocused ? theme.colorScheme.primary : theme.dividerColor, 51 - width: isFocused ? 2 : 1, 52 ), 53 - borderRadius: BorderRadius.circular(8), 54 - ), 55 - child: TextField( 56 - controller: controller, 57 - maxLines: maxLines, 58 - enabled: enabled, 59 - keyboardType: keyboardType, 60 - onChanged: onChanged, 61 - style: theme.textTheme.bodyMedium?.copyWith(fontSize: 15), 62 - decoration: InputDecoration( 63 - hintText: hintText, 64 - hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 65 - border: InputBorder.none, 66 - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 67 - isDense: true, 68 ), 69 - ), 70 ); 71 }, 72 ),
··· 36 const SizedBox(height: 6), 37 Container( 38 decoration: BoxDecoration( 39 + color: theme.brightness == Brightness.dark ? Colors.grey[850] : Colors.grey[200], 40 borderRadius: BorderRadius.circular(8), 41 ), 42 child: Focus( 43 child: Builder( 44 builder: (context) { 45 final isFocused = Focus.of(context).hasFocus; 46 + return Stack( 47 + children: [ 48 + // TextField with internal padding 49 + TextField( 50 + controller: controller, 51 + maxLines: maxLines, 52 + enabled: enabled, 53 + keyboardType: keyboardType, 54 + onChanged: onChanged, 55 + style: theme.textTheme.bodyMedium?.copyWith(fontSize: 15), 56 + decoration: InputDecoration( 57 + hintText: hintText, 58 + hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 59 + border: InputBorder.none, 60 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 61 + isDense: true, 62 + ), 63 ), 64 + // Border overlay 65 + Positioned.fill( 66 + child: IgnorePointer( 67 + child: AnimatedContainer( 68 + duration: const Duration(milliseconds: 150), 69 + decoration: BoxDecoration( 70 + border: Border.all( 71 + color: isFocused ? theme.colorScheme.primary : theme.dividerColor, 72 + width: isFocused ? 2 : 0, 73 + ), 74 + borderRadius: BorderRadius.circular(8), 75 + ), 76 + ), 77 + ), 78 ), 79 + ], 80 ); 81 }, 82 ),