Compare changes

Choose any two refs to compare.

+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 }
+2 -8
lib/api.dart
··· 26 26 27 27 Future<Session?> refreshSession(Session session) async { 28 28 final url = Uri.parse('$_apiUrl/api/token/refresh'); 29 - final headers = { 30 - 'Authorization': 'Bearer ${session.token}', 31 - 'Content-Type': 'application/json', 32 - }; 29 + final headers = {'Content-Type': 'application/json'}; 33 30 try { 34 31 final response = await http.post( 35 32 url, ··· 51 48 52 49 Future<bool> revokeSession(Session session) async { 53 50 final url = Uri.parse('$_apiUrl/api/token/revoke'); 54 - final headers = { 55 - 'Authorization': 'Bearer ${session.token}', 56 - 'Content-Type': 'application/json', 57 - }; 51 + final headers = {'Content-Type': 'application/json'}; 58 52 try { 59 53 final response = await http.post( 60 54 url,
-153
lib/dpop_client.dart
··· 1 - import 'dart:convert'; 2 - 3 - import 'package:crypto/crypto.dart'; 4 - import 'package:http/http.dart' as http; 5 - import 'package:jose/jose.dart'; 6 - import 'package:uuid/uuid.dart'; 7 - 8 - class DpopHttpClient { 9 - final JsonWebKey dpopKey; 10 - final Map<String, String> _nonces = {}; // origin -> nonce 11 - 12 - DpopHttpClient({required this.dpopKey}); 13 - 14 - /// Extract origin (scheme + host + port) from a URL 15 - String _extractOrigin(String url) { 16 - final uri = Uri.parse(url); 17 - final portPart = (uri.hasPort && uri.port != 80 && uri.port != 443) ? ':${uri.port}' : ''; 18 - return '${uri.scheme}://${uri.host}$portPart'; 19 - } 20 - 21 - /// Strip query and fragment from URL per spec 22 - String _buildHtu(String url) { 23 - final uri = Uri.parse(url); 24 - return '${uri.scheme}://${uri.host}${uri.path}'; 25 - } 26 - 27 - /// Calculate ath claim: base64url(sha256(access_token)) 28 - String _calculateAth(String accessToken) { 29 - final hash = sha256.convert(utf8.encode(accessToken)); 30 - return base64Url.encode(hash.bytes).replaceAll('=', ''); 31 - } 32 - 33 - /// Calculate the JWK Thumbprint for EC or RSA keys per RFC 7638. 34 - /// The input [jwk] is the public part of your key as a Map`<String, dynamic>`. 35 - /// 36 - /// For EC keys, required fields are: crv, kty, x, y 37 - /// For RSA keys, required fields are: e, kty, n 38 - String calculateJwkThumbprint(Map<String, dynamic> jwk) { 39 - late Map<String, String> ordered; 40 - 41 - if (jwk['kty'] == 'EC') { 42 - ordered = {'crv': jwk['crv'], 'kty': jwk['kty'], 'x': jwk['x'], 'y': jwk['y']}; 43 - } else if (jwk['kty'] == 'RSA') { 44 - ordered = {'e': jwk['e'], 'kty': jwk['kty'], 'n': jwk['n']}; 45 - } else { 46 - throw ArgumentError('Unsupported key type for thumbprint calculation'); 47 - } 48 - 49 - final jsonString = jsonEncode(ordered); 50 - 51 - final digest = sha256.convert(utf8.encode(jsonString)); 52 - return base64Url.encode(digest.bytes).replaceAll('=', ''); 53 - } 54 - 55 - /// Build the DPoP JWT proof 56 - Future<String> _buildProof({ 57 - required String htm, 58 - required String htu, 59 - String? nonce, 60 - String? ath, 61 - }) async { 62 - final now = (DateTime.now().millisecondsSinceEpoch / 1000).floor(); 63 - final jti = Uuid().v4(); 64 - 65 - final publicJwk = Map<String, String>.from(dpopKey.toJson())..remove('d'); 66 - 67 - final payload = { 68 - 'htu': htu, 69 - 'htm': htm, 70 - 'iat': now, 71 - 'jti': jti, 72 - if (nonce != null) 'nonce': nonce, 73 - if (ath != null) 'ath': ath, 74 - }; 75 - 76 - final builder = JsonWebSignatureBuilder() 77 - ..jsonContent = payload 78 - ..addRecipient(dpopKey, algorithm: dpopKey.algorithm) 79 - ..setProtectedHeader('typ', 'dpop+jwt') 80 - ..setProtectedHeader('jwk', publicJwk); 81 - 82 - final jws = builder.build(); 83 - return jws.toCompactSerialization(); 84 - } 85 - 86 - /// Public method to send requests with DPoP proof, retries once on use_dpop_nonce error 87 - Future<http.Response> send({ 88 - required String method, 89 - required Uri url, 90 - required String accessToken, 91 - Map<String, String>? headers, 92 - Object? body, 93 - }) async { 94 - final origin = _extractOrigin(url.toString()); 95 - final nonce = _nonces[origin]; 96 - 97 - final htu = _buildHtu(url.toString()); 98 - final ath = _calculateAth(accessToken); 99 - 100 - final proof = await _buildProof(htm: method.toUpperCase(), htu: htu, nonce: nonce, ath: ath); 101 - 102 - // Compose headers, allowing override of Content-Type for raw uploads 103 - final requestHeaders = <String, String>{ 104 - 'Authorization': 'DPoP $accessToken', 105 - 'DPoP': proof, 106 - if (headers != null) ...headers, 107 - }; 108 - 109 - http.Response response; 110 - switch (method.toUpperCase()) { 111 - case 'GET': 112 - response = await http.get(url, headers: requestHeaders); 113 - break; 114 - case 'POST': 115 - response = await http.post(url, headers: requestHeaders, body: body); 116 - break; 117 - case 'PUT': 118 - response = await http.put(url, headers: requestHeaders, body: body); 119 - break; 120 - case 'DELETE': 121 - response = await http.delete(url, headers: requestHeaders, body: body); 122 - break; 123 - default: 124 - throw UnsupportedError('Unsupported HTTP method: $method'); 125 - } 126 - 127 - final newNonce = response.headers['dpop-nonce']; 128 - if (newNonce != null && newNonce != nonce) { 129 - // Save new nonce for origin 130 - _nonces[origin] = newNonce; 131 - } 132 - 133 - if (response.statusCode == 401) { 134 - final wwwAuth = response.headers['www-authenticate']; 135 - if (wwwAuth != null && 136 - wwwAuth.contains('DPoP') && 137 - wwwAuth.contains('error="use_dpop_nonce"') && 138 - newNonce != null && 139 - newNonce != nonce) { 140 - // Retry once with updated nonce 141 - return send( 142 - method: method, 143 - url: url, 144 - accessToken: accessToken, 145 - headers: headers, 146 - body: body, 147 - ); 148 - } 149 - } 150 - 151 - return response; 152 - } 153 - }
+1
lib/models/photo_exif.dart
··· 20 20 String? lensModel, 21 21 String? make, 22 22 String? model, 23 + Map<String, dynamic>? record, 23 24 }) = _PhotoExif; 24 25 25 26 factory PhotoExif.fromJson(Map<String, dynamic> json) => _$PhotoExifFromJson(json);
+31 -3
lib/models/photo_exif.freezed.dart
··· 36 36 String? get lensModel => throw _privateConstructorUsedError; 37 37 String? get make => throw _privateConstructorUsedError; 38 38 String? get model => throw _privateConstructorUsedError; 39 + Map<String, dynamic>? get record => throw _privateConstructorUsedError; 39 40 40 41 /// Serializes this PhotoExif to a JSON map. 41 42 Map<String, dynamic> toJson() => throw _privateConstructorUsedError; ··· 67 68 String? lensModel, 68 69 String? make, 69 70 String? model, 71 + Map<String, dynamic>? record, 70 72 }); 71 73 } 72 74 ··· 99 101 Object? lensModel = freezed, 100 102 Object? make = freezed, 101 103 Object? model = freezed, 104 + Object? record = freezed, 102 105 }) { 103 106 return _then( 104 107 _value.copyWith( ··· 158 161 ? _value.model 159 162 : model // ignore: cast_nullable_to_non_nullable 160 163 as String?, 164 + record: freezed == record 165 + ? _value.record 166 + : record // ignore: cast_nullable_to_non_nullable 167 + as Map<String, dynamic>?, 161 168 ) 162 169 as $Val, 163 170 ); ··· 188 195 String? lensModel, 189 196 String? make, 190 197 String? model, 198 + Map<String, dynamic>? record, 191 199 }); 192 200 } 193 201 ··· 219 227 Object? lensModel = freezed, 220 228 Object? make = freezed, 221 229 Object? model = freezed, 230 + Object? record = freezed, 222 231 }) { 223 232 return _then( 224 233 _$PhotoExifImpl( ··· 278 287 ? _value.model 279 288 : model // ignore: cast_nullable_to_non_nullable 280 289 as String?, 290 + record: freezed == record 291 + ? _value._record 292 + : record // ignore: cast_nullable_to_non_nullable 293 + as Map<String, dynamic>?, 281 294 ), 282 295 ); 283 296 } ··· 301 314 this.lensModel, 302 315 this.make, 303 316 this.model, 304 - }); 317 + final Map<String, dynamic>? record, 318 + }) : _record = record; 305 319 306 320 factory _$PhotoExifImpl.fromJson(Map<String, dynamic> json) => 307 321 _$$PhotoExifImplFromJson(json); ··· 339 353 final String? make; 340 354 @override 341 355 final String? model; 356 + final Map<String, dynamic>? _record; 357 + @override 358 + Map<String, dynamic>? get record { 359 + final value = _record; 360 + if (value == null) return null; 361 + if (_record is EqualUnmodifiableMapView) return _record; 362 + // ignore: implicit_dynamic_type 363 + return EqualUnmodifiableMapView(value); 364 + } 342 365 343 366 @override 344 367 String toString() { 345 - return 'PhotoExif(photo: $photo, createdAt: $createdAt, uri: $uri, cid: $cid, dateTimeOriginal: $dateTimeOriginal, exposureTime: $exposureTime, fNumber: $fNumber, flash: $flash, focalLengthIn35mmFormat: $focalLengthIn35mmFormat, iSO: $iSO, lensMake: $lensMake, lensModel: $lensModel, make: $make, model: $model)'; 368 + return 'PhotoExif(photo: $photo, createdAt: $createdAt, uri: $uri, cid: $cid, dateTimeOriginal: $dateTimeOriginal, exposureTime: $exposureTime, fNumber: $fNumber, flash: $flash, focalLengthIn35mmFormat: $focalLengthIn35mmFormat, iSO: $iSO, lensMake: $lensMake, lensModel: $lensModel, make: $make, model: $model, record: $record)'; 346 369 } 347 370 348 371 @override ··· 372 395 (identical(other.lensModel, lensModel) || 373 396 other.lensModel == lensModel) && 374 397 (identical(other.make, make) || other.make == make) && 375 - (identical(other.model, model) || other.model == model)); 398 + (identical(other.model, model) || other.model == model) && 399 + const DeepCollectionEquality().equals(other._record, _record)); 376 400 } 377 401 378 402 @JsonKey(includeFromJson: false, includeToJson: false) ··· 393 417 lensModel, 394 418 make, 395 419 model, 420 + const DeepCollectionEquality().hash(_record), 396 421 ); 397 422 398 423 /// Create a copy of PhotoExif ··· 425 450 final String? lensModel, 426 451 final String? make, 427 452 final String? model, 453 + final Map<String, dynamic>? record, 428 454 }) = _$PhotoExifImpl; 429 455 430 456 factory _PhotoExif.fromJson(Map<String, dynamic> json) = ··· 458 484 String? get make; 459 485 @override 460 486 String? get model; 487 + @override 488 + Map<String, dynamic>? get record; 461 489 462 490 /// Create a copy of PhotoExif 463 491 /// with the given fields replaced by the non-null parameter values.
+2
lib/models/photo_exif.g.dart
··· 22 22 lensModel: json['lensModel'] as String?, 23 23 make: json['make'] as String?, 24 24 model: json['model'] as String?, 25 + record: json['record'] as Map<String, dynamic>?, 25 26 ); 26 27 27 28 Map<String, dynamic> _$$PhotoExifImplToJson(_$PhotoExifImpl instance) => ··· 40 41 'lensModel': instance.lensModel, 41 42 'make': instance.make, 42 43 'model': instance.model, 44 + 'record': instance.record, 43 45 };
+22
lib/providers/actor_search_provider.dart
··· 1 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 + 3 + import '../api.dart'; 4 + import '../models/profile.dart'; 5 + 6 + final actorSearchProvider = StateNotifierProvider<ActorSearchNotifier, Map<String, List<Profile>>>( 7 + (ref) => ActorSearchNotifier(), 8 + ); 9 + 10 + class ActorSearchNotifier extends StateNotifier<Map<String, List<Profile>>> { 11 + ActorSearchNotifier() : super({}); 12 + 13 + Future<List<Profile>> search(String query) async { 14 + if (query.isEmpty) return []; 15 + if (state.containsKey(query)) { 16 + return state[query]!; 17 + } 18 + final results = await apiService.searchActors(query); 19 + state = {...state, query: results}; 20 + return results; 21 + } 22 + }
+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 }
+1 -1
lib/providers/gallery_cache_provider.g.dart
··· 6 6 // RiverpodGenerator 7 7 // ************************************************************************** 8 8 9 - String _$galleryCacheHash() => r'd74ced0d6fcf6369bed80f7f0219bd591c13db5a'; 9 + String _$galleryCacheHash() => r'd604bfc71f008251a36d7943b99294728c31de1f'; 10 10 11 11 /// Holds a cache of galleries by URI. 12 12 ///
+5 -31
lib/providers/profile_provider.dart
··· 22 22 return _fetchProfile(did); 23 23 } 24 24 25 - // @TODO: Facets don't always render correctly. 26 - List<Map<String, dynamic>>? _filterValidFacets( 27 - List<Map<String, dynamic>>? computedFacets, 28 - String desc, 29 - ) { 30 - if (computedFacets == null) return null; 31 - return computedFacets.where((facet) { 32 - final index = facet['index']; 33 - if (index is Map) { 34 - final start = index['byteStart'] ?? 0; 35 - final end = index['byteEnd'] ?? 0; 36 - return start is int && end is int && start >= 0 && end > start && end <= desc.length; 37 - } 38 - final start = facet['index'] ?? facet['offset'] ?? 0; 39 - final end = facet['end']; 40 - final length = facet['length']; 41 - if (end is int && start is int) { 42 - return start >= 0 && end > start && end <= desc.length; 43 - } else if (length is int && start is int) { 44 - return start >= 0 && length > 0 && start + length <= desc.length; 45 - } 46 - return false; 47 - }).toList(); 48 - } 49 - 50 - // Extract facet computation and filtering for reuse 51 - Future<List<Map<String, dynamic>>?> computeAndFilterFacets(String? description) async { 25 + // Extract facets 26 + Future<List<Map<String, dynamic>>?> _extractFacets(String? description) async { 52 27 final desc = description ?? ''; 53 28 if (desc.isEmpty) return null; 54 29 try { 55 30 final blueskyText = BlueskyText(desc); 56 31 final entities = blueskyText.entities; 57 - final computedFacets = await entities.toFacets(); 58 - return _filterValidFacets(computedFacets, desc); 32 + return entities.toFacets(); 59 33 } catch (_) { 60 34 return null; 61 35 } ··· 66 40 final galleries = await apiService.fetchActorGalleries(did: did); 67 41 final favs = await apiService.getActorFavs(did: did); 68 42 if (profile != null) { 69 - final facets = await computeAndFilterFacets(profile.description); 43 + final facets = await _extractFacets(profile.description); 70 44 return ProfileWithGalleries( 71 45 profile: profile.copyWith(descriptionFacets: facets), 72 46 galleries: galleries, ··· 108 82 final updated = await apiService.fetchProfile(did: did); 109 83 if (updated != null) { 110 84 final galleries = await apiService.fetchActorGalleries(did: did); 111 - final facets = await computeAndFilterFacets(updated.description); 85 + final facets = await _extractFacets(updated.description); 112 86 ref.read(galleryCacheProvider.notifier).setGalleriesForActor(did, galleries); 113 87 state = AsyncValue.data( 114 88 ProfileWithGalleries(
+1 -1
lib/providers/profile_provider.g.dart
··· 6 6 // RiverpodGenerator 7 7 // ************************************************************************** 8 8 9 - String _$profileNotifierHash() => r'48159a8319bba2f2ec5462c50d80ba6a5b72d91e'; 9 + String _$profileNotifierHash() => r'4b8e3a8d4363beb885ead4ae7ce9c52101a6bf96'; 10 10 11 11 /// Copied from Dart SDK 12 12 class _SystemHash {
+11 -4
lib/screens/gallery_edit_photos_sheet.dart
··· 222 222 context, 223 223 actorDid: actorDid, 224 224 galleryUri: widget.galleryUri, 225 - onSelect: (photos) { 226 - setState(() { 227 - _photos.addAll(photos); 228 - }); 225 + onSelect: (photos) async { 226 + // Wait for provider to update after adding 227 + await Future.delayed(const Duration(milliseconds: 100)); 228 + final updatedGallery = ref.read( 229 + galleryCacheProvider, 230 + )[widget.galleryUri]; 231 + if (updatedGallery != null && mounted) { 232 + setState(() { 233 + _photos = List.from(updatedGallery.items); 234 + }); 235 + } 229 236 }, 230 237 ); 231 238 },
+8 -10
lib/screens/gallery_page.dart
··· 273 273 ); 274 274 } 275 275 : null, 276 - child: Row( 277 - crossAxisAlignment: CrossAxisAlignment.center, 276 + child: Column( 277 + crossAxisAlignment: CrossAxisAlignment.start, 278 278 children: [ 279 279 Text( 280 280 gallery.creator?.displayName ?? '', ··· 282 282 fontWeight: FontWeight.w600, 283 283 ), 284 284 ), 285 - if ((gallery.creator?.displayName ?? '').isNotEmpty && 286 - (gallery.creator?.handle ?? '').isNotEmpty) 287 - const SizedBox(width: 8), 288 - Text( 289 - '@${gallery.creator?.handle ?? ''}', 290 - style: theme.textTheme.bodyMedium?.copyWith( 291 - color: theme.hintColor, 285 + if ((gallery.creator?.handle ?? '').isNotEmpty) 286 + Text( 287 + '@${gallery.creator?.handle ?? ''}', 288 + style: theme.textTheme.bodyMedium?.copyWith( 289 + color: theme.hintColor, 290 + ), 292 291 ), 293 - ), 294 292 ], 295 293 ), 296 294 ),
+6 -1
lib/screens/library_photos_select_sheet.dart
··· 40 40 Future<void> _fetchPhotos() async { 41 41 setState(() => _loading = true); 42 42 final photos = await apiService.fetchActorPhotos(did: widget.actorDid); 43 + // Get gallery items from provider 44 + final galleryItems = ref.read(galleryCacheProvider)[widget.galleryUri]?.items ?? []; 45 + final galleryUris = galleryItems.map((item) => item.uri).toSet(); 46 + // Filter out photos already in gallery 47 + final filteredPhotos = photos.where((photo) => !galleryUris.contains(photo.uri)).toList(); 43 48 if (mounted) { 44 49 setState(() { 45 - _photos = photos; 50 + _photos = filteredPhotos; 46 51 _loading = false; 47 52 }); 48 53 }
+601
lib/screens/photo_library_page.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:grain/api.dart'; 3 + import 'package:grain/app_icons.dart'; 4 + import 'package:grain/models/gallery_photo.dart'; 5 + import 'package:grain/widgets/app_image.dart'; 6 + import 'package:grain/widgets/gallery_photo_view.dart'; 7 + 8 + class PhotoGroup { 9 + final String title; 10 + final List<GalleryPhoto> photos; 11 + final DateTime? sortDate; 12 + 13 + PhotoGroup({required this.title, required this.photos, this.sortDate}); 14 + } 15 + 16 + class PhotoLibraryPage extends StatefulWidget { 17 + const PhotoLibraryPage({super.key}); 18 + 19 + @override 20 + State<PhotoLibraryPage> createState() => _PhotoLibraryPageState(); 21 + } 22 + 23 + class _PhotoLibraryPageState extends State<PhotoLibraryPage> { 24 + List<GalleryPhoto> _photos = []; 25 + List<PhotoGroup> _photoGroups = []; 26 + bool _isLoading = true; 27 + String? _error; 28 + final ScrollController _scrollController = ScrollController(); 29 + double _scrollPosition = 0.0; 30 + 31 + @override 32 + void initState() { 33 + super.initState(); 34 + _loadPhotos(); 35 + _scrollController.addListener(_onScroll); 36 + } 37 + 38 + @override 39 + void dispose() { 40 + _scrollController.removeListener(_onScroll); 41 + _scrollController.dispose(); 42 + super.dispose(); 43 + } 44 + 45 + void _onScroll() { 46 + if (_scrollController.hasClients) { 47 + setState(() { 48 + _scrollPosition = _scrollController.offset; 49 + }); 50 + } 51 + } 52 + 53 + // Calculate which group is currently in view based on scroll position 54 + int _getCurrentGroupIndex() { 55 + if (!_scrollController.hasClients || _photoGroups.isEmpty) return 0; 56 + 57 + final scrollOffset = _scrollController.offset; 58 + final padding = 16.0; // ListView padding 59 + double currentOffset = padding; 60 + 61 + for (int i = 0; i < _photoGroups.length; i++) { 62 + final group = _photoGroups[i]; 63 + 64 + // Add space for group title 65 + final titleHeight = 24.0 + 12.0 + (i == 0 ? 0 : 24.0); // title + padding + top margin 66 + currentOffset += titleHeight; 67 + 68 + // Calculate grid height for this group 69 + final photos = group.photos; 70 + final crossAxisCount = photos.length == 1 ? 1 : (photos.length == 2 ? 2 : 3); 71 + final aspectRatio = photos.length <= 2 ? 1.5 : 1.0; 72 + final rows = (photos.length / crossAxisCount).ceil(); 73 + 74 + // Estimate grid item size based on screen width 75 + final screenWidth = MediaQuery.of(context).size.width; 76 + final gridPadding = 30.0 + 32.0; // right padding + left/right margins 77 + final availableWidth = screenWidth - gridPadding; 78 + final itemWidth = (availableWidth - (crossAxisCount - 1) * 4) / crossAxisCount; 79 + final itemHeight = itemWidth / aspectRatio; 80 + final gridHeight = rows * itemHeight + (rows - 1) * 4; // include spacing 81 + 82 + currentOffset += gridHeight; 83 + 84 + // Check if we're currently viewing this group 85 + if (scrollOffset < currentOffset) { 86 + return i; 87 + } 88 + } 89 + 90 + return _photoGroups.length - 1; // Return last group if we're at the bottom 91 + } 92 + 93 + Future<void> _loadPhotos() async { 94 + setState(() { 95 + _isLoading = true; 96 + _error = null; 97 + }); 98 + 99 + try { 100 + final currentUser = apiService.currentUser; 101 + if (currentUser == null || currentUser.did.isEmpty) { 102 + setState(() { 103 + _error = 'No current user found'; 104 + _isLoading = false; 105 + }); 106 + return; 107 + } 108 + 109 + final photos = await apiService.fetchActorPhotos(did: currentUser.did); 110 + 111 + if (mounted) { 112 + setState(() { 113 + _photos = photos; 114 + _photoGroups = _groupPhotosByDate(photos); 115 + _isLoading = false; 116 + }); 117 + 118 + // Force update scroll indicator after layout is complete 119 + WidgetsBinding.instance.addPostFrameCallback((_) { 120 + if (_scrollController.hasClients && mounted) { 121 + setState(() { 122 + _scrollPosition = _scrollController.offset; 123 + }); 124 + } 125 + }); 126 + } 127 + } catch (e) { 128 + if (mounted) { 129 + setState(() { 130 + _error = 'Failed to load photos: $e'; 131 + _isLoading = false; 132 + }); 133 + } 134 + } 135 + } 136 + 137 + List<PhotoGroup> _groupPhotosByDate(List<GalleryPhoto> photos) { 138 + final now = DateTime.now(); 139 + final today = DateTime(now.year, now.month, now.day); 140 + final yesterday = today.subtract(const Duration(days: 1)); 141 + 142 + final Map<String, List<GalleryPhoto>> groupedPhotos = {}; 143 + final List<GalleryPhoto> noExifPhotos = []; 144 + 145 + for (final photo in photos) { 146 + DateTime? photoDate; 147 + // Try to parse the dateTimeOriginal from EXIF record data 148 + if (photo.exif?.record?['dateTimeOriginal'] != null) { 149 + try { 150 + final dateTimeOriginal = photo.exif!.record!['dateTimeOriginal'] as String; 151 + photoDate = DateTime.parse(dateTimeOriginal); 152 + } catch (e) { 153 + // If parsing fails, add to no EXIF group 154 + noExifPhotos.add(photo); 155 + continue; 156 + } 157 + } else { 158 + noExifPhotos.add(photo); 159 + continue; 160 + } 161 + 162 + final photoDay = DateTime(photoDate.year, photoDate.month, photoDate.day); 163 + String groupKey; 164 + 165 + if (photoDay.isAtSameMomentAs(today)) { 166 + groupKey = 'Today'; 167 + } else if (photoDay.isAtSameMomentAs(yesterday)) { 168 + groupKey = 'Yesterday'; 169 + } else { 170 + final daysDifference = today.difference(photoDay).inDays; 171 + 172 + if (daysDifference <= 30) { 173 + // Group by week for last 30 days 174 + final weekStart = photoDay.subtract(Duration(days: photoDay.weekday - 1)); 175 + groupKey = 'Week of ${_formatDate(weekStart)}'; 176 + } else { 177 + // Group by month for older photos 178 + groupKey = '${_getMonthName(photoDate.month)} ${photoDate.year}'; 179 + } 180 + } 181 + 182 + groupedPhotos.putIfAbsent(groupKey, () => []).add(photo); 183 + } 184 + 185 + final List<PhotoGroup> groups = []; 186 + 187 + // Sort and create PhotoGroup objects 188 + final sortedEntries = groupedPhotos.entries.toList() 189 + ..sort((a, b) { 190 + final aDate = _getGroupSortDate(a.key, a.value); 191 + final bDate = _getGroupSortDate(b.key, b.value); 192 + return bDate.compareTo(aDate); // Most recent first 193 + }); 194 + 195 + for (final entry in sortedEntries) { 196 + final sortedPhotos = entry.value 197 + ..sort((a, b) { 198 + final aDate = _getPhotoDate(a); 199 + final bDate = _getPhotoDate(b); 200 + return bDate.compareTo(aDate); // Most recent first within group 201 + }); 202 + 203 + groups.add( 204 + PhotoGroup( 205 + title: entry.key, 206 + photos: sortedPhotos, 207 + sortDate: _getGroupSortDate(entry.key, entry.value), 208 + ), 209 + ); 210 + } 211 + 212 + // Add photos without EXIF data at the end 213 + if (noExifPhotos.isNotEmpty) { 214 + groups.add( 215 + PhotoGroup( 216 + title: 'Photos without date info', 217 + photos: noExifPhotos, 218 + sortDate: DateTime(1970), // Very old date to sort at bottom 219 + ), 220 + ); 221 + } 222 + 223 + return groups; 224 + } 225 + 226 + DateTime _getGroupSortDate(String groupKey, List<GalleryPhoto> photos) { 227 + if (groupKey == 'Today') return DateTime.now(); 228 + if (groupKey == 'Yesterday') return DateTime.now().subtract(const Duration(days: 1)); 229 + 230 + // For other groups, use the most recent photo date in the group 231 + DateTime? latestDate; 232 + for (final photo in photos) { 233 + final photoDate = _getPhotoDate(photo); 234 + if (latestDate == null || photoDate.isAfter(latestDate)) { 235 + latestDate = photoDate; 236 + } 237 + } 238 + return latestDate ?? DateTime(1970); 239 + } 240 + 241 + DateTime _getPhotoDate(GalleryPhoto photo) { 242 + if (photo.exif?.record?['dateTimeOriginal'] != null) { 243 + try { 244 + final dateTimeOriginal = photo.exif!.record!['dateTimeOriginal'] as String; 245 + return DateTime.parse(dateTimeOriginal); 246 + } catch (e) { 247 + // Fall back to a very old date if parsing fails 248 + return DateTime(1970); 249 + } 250 + } 251 + return DateTime(1970); 252 + } 253 + 254 + String _formatDate(DateTime date) { 255 + const months = [ 256 + 'Jan', 257 + 'Feb', 258 + 'Mar', 259 + 'Apr', 260 + 'May', 261 + 'Jun', 262 + 'Jul', 263 + 'Aug', 264 + 'Sep', 265 + 'Oct', 266 + 'Nov', 267 + 'Dec', 268 + ]; 269 + return '${months[date.month - 1]} ${date.day}'; 270 + } 271 + 272 + String _getMonthName(int month) { 273 + const months = [ 274 + 'January', 275 + 'February', 276 + 'March', 277 + 'April', 278 + 'May', 279 + 'June', 280 + 'July', 281 + 'August', 282 + 'September', 283 + 'October', 284 + 'November', 285 + 'December', 286 + ]; 287 + return months[month - 1]; 288 + } 289 + 290 + Future<void> _onRefresh() async { 291 + await _loadPhotos(); 292 + } 293 + 294 + void _showPhotoDetail(GalleryPhoto photo) { 295 + // Create a flattened list of photos in the same order they appear on the page 296 + final List<GalleryPhoto> orderedPhotos = []; 297 + for (final group in _photoGroups) { 298 + orderedPhotos.addAll(group.photos); 299 + } 300 + 301 + // Find the index of the photo in the ordered list 302 + final photoIndex = orderedPhotos.indexOf(photo); 303 + if (photoIndex == -1) return; // Photo not found, shouldn't happen 304 + 305 + Navigator.of(context).push( 306 + PageRouteBuilder( 307 + pageBuilder: (context, animation, secondaryAnimation) => GalleryPhotoView( 308 + photos: orderedPhotos, 309 + initialIndex: photoIndex, 310 + showAddCommentButton: false, 311 + onClose: () => Navigator.of(context).pop(), 312 + ), 313 + transitionDuration: const Duration(milliseconds: 200), 314 + reverseTransitionDuration: const Duration(milliseconds: 200), 315 + transitionsBuilder: (context, animation, secondaryAnimation, child) { 316 + return FadeTransition(opacity: animation, child: child); 317 + }, 318 + ), 319 + ); 320 + } 321 + 322 + @override 323 + Widget build(BuildContext context) { 324 + final theme = Theme.of(context); 325 + 326 + return Scaffold( 327 + backgroundColor: theme.scaffoldBackgroundColor, 328 + appBar: AppBar( 329 + title: const Text('Photo Library'), 330 + backgroundColor: theme.appBarTheme.backgroundColor, 331 + surfaceTintColor: theme.appBarTheme.backgroundColor, 332 + elevation: 0, 333 + ), 334 + body: RefreshIndicator(onRefresh: _onRefresh, child: _buildBodyWithScrollbar(theme)), 335 + ); 336 + } 337 + 338 + Widget _buildBodyWithScrollbar(ThemeData theme) { 339 + return Stack( 340 + children: [ 341 + Padding( 342 + padding: const EdgeInsets.only(right: 30), // Make room for scroll indicator 343 + child: _buildBody(theme), 344 + ), 345 + if (!_isLoading && _error == null && _photos.isNotEmpty) _buildScrollIndicator(theme), 346 + ], 347 + ); 348 + } 349 + 350 + Widget _buildScrollIndicator(ThemeData theme) { 351 + return Positioned( 352 + right: 4, 353 + top: 0, 354 + bottom: 0, 355 + child: GestureDetector( 356 + onPanUpdate: (details) { 357 + if (_scrollController.hasClients) { 358 + final RenderBox renderBox = context.findRenderObject() as RenderBox; 359 + final localPosition = renderBox.globalToLocal(details.globalPosition); 360 + final screenHeight = renderBox.size.height; 361 + final maxScrollExtent = _scrollController.position.maxScrollExtent; 362 + final relativePosition = (localPosition.dy / screenHeight).clamp(0.0, 1.0); 363 + final newPosition = relativePosition * maxScrollExtent; 364 + _scrollController.jumpTo(newPosition.clamp(0.0, maxScrollExtent)); 365 + } 366 + }, 367 + onTapDown: (details) { 368 + if (_scrollController.hasClients) { 369 + final RenderBox renderBox = context.findRenderObject() as RenderBox; 370 + final localPosition = renderBox.globalToLocal(details.globalPosition); 371 + final screenHeight = renderBox.size.height; 372 + final maxScrollExtent = _scrollController.position.maxScrollExtent; 373 + final relativePosition = (localPosition.dy / screenHeight).clamp(0.0, 1.0); 374 + final newPosition = relativePosition * maxScrollExtent; 375 + _scrollController.animateTo( 376 + newPosition.clamp(0.0, maxScrollExtent), 377 + duration: const Duration(milliseconds: 200), 378 + curve: Curves.easeInOut, 379 + ); 380 + } 381 + }, 382 + child: Container( 383 + width: 24, 384 + decoration: BoxDecoration( 385 + color: theme.scaffoldBackgroundColor.withValues(alpha: 0.8), 386 + borderRadius: BorderRadius.circular(12), 387 + ), 388 + child: CustomPaint( 389 + painter: ScrollIndicatorPainter( 390 + scrollPosition: _scrollPosition, 391 + maxScrollExtent: _scrollController.hasClients 392 + ? _scrollController.position.maxScrollExtent 393 + : 0, 394 + viewportHeight: _scrollController.hasClients 395 + ? _scrollController.position.viewportDimension 396 + : 0, 397 + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), 398 + activeColor: theme.colorScheme.primary, 399 + currentGroupIndex: _getCurrentGroupIndex(), 400 + totalGroups: _photoGroups.length, 401 + ), 402 + ), 403 + ), 404 + ), 405 + ); 406 + } 407 + 408 + Widget _buildBody(ThemeData theme) { 409 + if (_isLoading) { 410 + return const Center(child: CircularProgressIndicator()); 411 + } 412 + 413 + if (_error != null) { 414 + return Center( 415 + child: Column( 416 + mainAxisAlignment: MainAxisAlignment.center, 417 + children: [ 418 + Icon(AppIcons.brokenImage, size: 64, color: theme.hintColor), 419 + const SizedBox(height: 16), 420 + Text( 421 + _error!, 422 + style: theme.textTheme.bodyLarge?.copyWith(color: theme.hintColor), 423 + textAlign: TextAlign.center, 424 + ), 425 + const SizedBox(height: 16), 426 + ElevatedButton(onPressed: _loadPhotos, child: const Text('Retry')), 427 + ], 428 + ), 429 + ); 430 + } 431 + 432 + if (_photos.isEmpty) { 433 + return Center( 434 + child: Column( 435 + mainAxisAlignment: MainAxisAlignment.center, 436 + children: [ 437 + Icon(AppIcons.photoLibrary, size: 64, color: theme.hintColor), 438 + const SizedBox(height: 16), 439 + Text( 440 + 'No photos yet', 441 + style: theme.textTheme.headlineSmall?.copyWith(color: theme.hintColor), 442 + ), 443 + const SizedBox(height: 8), 444 + Text( 445 + 'Upload some photos to see them here', 446 + style: theme.textTheme.bodyLarge?.copyWith(color: theme.hintColor), 447 + textAlign: TextAlign.center, 448 + ), 449 + ], 450 + ), 451 + ); 452 + } 453 + 454 + return ListView.builder( 455 + controller: _scrollController, 456 + padding: const EdgeInsets.all(16), 457 + itemCount: _photoGroups.length, 458 + itemBuilder: (context, index) { 459 + final group = _photoGroups[index]; 460 + return _buildPhotoGroup(group, theme, index); 461 + }, 462 + ); 463 + } 464 + 465 + Widget _buildPhotoGroup(PhotoGroup group, ThemeData theme, int index) { 466 + return Column( 467 + crossAxisAlignment: CrossAxisAlignment.start, 468 + children: [ 469 + Padding( 470 + padding: EdgeInsets.only(bottom: 12, top: index == 0 ? 0 : 24), 471 + child: Text( 472 + group.title, 473 + style: theme.textTheme.headlineSmall?.copyWith( 474 + fontWeight: FontWeight.bold, 475 + color: theme.colorScheme.onSurface, 476 + ), 477 + ), 478 + ), 479 + _buildPhotoGrid(group.photos, theme), 480 + ], 481 + ); 482 + } 483 + 484 + Widget _buildPhotoGrid(List<GalleryPhoto> photos, ThemeData theme) { 485 + final crossAxisCount = photos.length == 1 ? 1 : (photos.length == 2 ? 2 : 3); 486 + final aspectRatio = photos.length <= 2 ? 1.5 : 1.0; 487 + 488 + return GridView.builder( 489 + shrinkWrap: true, 490 + physics: const NeverScrollableScrollPhysics(), 491 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 492 + crossAxisCount: crossAxisCount, 493 + crossAxisSpacing: 4, 494 + mainAxisSpacing: 4, 495 + childAspectRatio: aspectRatio, 496 + ), 497 + itemCount: photos.length, 498 + itemBuilder: (context, index) { 499 + final photo = photos[index]; 500 + return _buildPhotoTile(photo, theme); 501 + }, 502 + ); 503 + } 504 + 505 + Widget _buildPhotoTile(GalleryPhoto photo, ThemeData theme) { 506 + return GestureDetector( 507 + onTap: () => _showPhotoDetail(photo), 508 + child: Hero( 509 + tag: 'photo-${photo.uri}', 510 + child: Container( 511 + decoration: BoxDecoration(borderRadius: BorderRadius.circular(8), color: theme.cardColor), 512 + clipBehavior: Clip.antiAlias, 513 + child: AppImage( 514 + url: photo.thumb ?? photo.fullsize, 515 + fit: BoxFit.cover, 516 + width: double.infinity, 517 + height: double.infinity, 518 + placeholder: Container( 519 + color: theme.hintColor.withValues(alpha: 0.1), 520 + child: Icon(AppIcons.photo, color: theme.hintColor, size: 32), 521 + ), 522 + errorWidget: Container( 523 + color: theme.hintColor.withValues(alpha: 0.1), 524 + child: Icon(AppIcons.brokenImage, color: theme.hintColor, size: 32), 525 + ), 526 + ), 527 + ), 528 + ), 529 + ); 530 + } 531 + } 532 + 533 + class ScrollIndicatorPainter extends CustomPainter { 534 + final double scrollPosition; 535 + final double maxScrollExtent; 536 + final double viewportHeight; 537 + final Color color; 538 + final Color activeColor; 539 + final int currentGroupIndex; 540 + final int totalGroups; 541 + 542 + ScrollIndicatorPainter({ 543 + required this.scrollPosition, 544 + required this.maxScrollExtent, 545 + required this.viewportHeight, 546 + required this.color, 547 + required this.activeColor, 548 + required this.currentGroupIndex, 549 + required this.totalGroups, 550 + }); 551 + 552 + @override 553 + void paint(Canvas canvas, Size size) { 554 + const dashCount = 60; // Number of dashes to show (doubled from 30) 555 + const dashHeight = 2.0; // Height when vertical (now width) 556 + const dashWidth = 12.0; // Width when vertical (now height) 557 + 558 + // Calculate spacing to fill the full height 559 + final availableHeight = size.height; 560 + final totalDashHeight = dashCount * dashHeight; 561 + final totalSpacing = availableHeight - totalDashHeight; 562 + final dashSpacing = totalSpacing / (dashCount - 1); 563 + 564 + // Calculate which dash should be active based on current group and total groups 565 + int activeDashIndex; 566 + if (totalGroups > 0) { 567 + // Map current group to dash index (more accurate than scroll position) 568 + final groupProgress = currentGroupIndex / (totalGroups - 1).clamp(1, totalGroups); 569 + activeDashIndex = (groupProgress * (dashCount - 1)).round().clamp(0, dashCount - 1); 570 + } else { 571 + // Fallback to scroll position if no groups 572 + final scrollProgress = maxScrollExtent > 0 573 + ? (scrollPosition / maxScrollExtent).clamp(0.0, 1.0) 574 + : 0.0; 575 + activeDashIndex = (scrollProgress * (dashCount - 1)).round(); 576 + } 577 + 578 + for (int i = 0; i < dashCount; i++) { 579 + final y = i * (dashHeight + dashSpacing); 580 + final isActive = i == activeDashIndex; 581 + 582 + final paint = Paint() 583 + ..color = isActive ? activeColor : color 584 + ..style = PaintingStyle.fill; 585 + 586 + // Create vertical dashes (rotated 90 degrees) 587 + final rect = Rect.fromLTWH((size.width - dashWidth) / 2, y, dashWidth, dashHeight); 588 + 589 + canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1)), paint); 590 + } 591 + } 592 + 593 + @override 594 + bool shouldRepaint(ScrollIndicatorPainter oldDelegate) { 595 + return scrollPosition != oldDelegate.scrollPosition || 596 + maxScrollExtent != oldDelegate.maxScrollExtent || 597 + viewportHeight != oldDelegate.viewportHeight || 598 + currentGroupIndex != oldDelegate.currentGroupIndex || 599 + totalGroups != oldDelegate.totalGroups; 600 + } 601 + }
+4 -2
lib/screens/profile_page.dart
··· 47 47 if (!mounted) return; 48 48 if (success) { 49 49 Navigator.of(context).pop(); 50 - if (mounted) setState(() {}); // Force widget rebuild after modal closes 50 + if (mounted) { 51 + setState(() {}); // Force widget rebuild after modal closes 52 + } 51 53 } else { 52 54 if (!mounted) return; 53 55 ScaffoldMessenger.of( ··· 194 196 onPressed: () async { 195 197 await ref 196 198 .read(profileNotifierProvider(profile.did).notifier) 197 - .toggleFollow(apiService.currentUser?.did); 199 + .toggleFollow(profile.did); 198 200 }, 199 201 label: (profile.viewer?.following?.isNotEmpty == true) 200 202 ? 'Following'
+310
lib/utils/facet_utils.dart
··· 1 + import 'package:flutter/gestures.dart'; 2 + import 'package:flutter/material.dart'; 3 + 4 + class FacetRange { 5 + final int start; 6 + final int end; 7 + final String? type; 8 + final Map<String, dynamic> data; 9 + 10 + FacetRange({required this.start, required this.end, required this.type, required this.data}); 11 + } 12 + 13 + class ProcessedSpan { 14 + final int start; 15 + final int end; 16 + final TextSpan span; 17 + 18 + ProcessedSpan({required this.start, required this.end, required this.span}); 19 + } 20 + 21 + class FacetUtils { 22 + /// Processes facets and returns a list of TextSpans with proper highlighting 23 + static List<TextSpan> processFacets({ 24 + required String text, 25 + required List<Map<String, dynamic>>? facets, 26 + required TextStyle? defaultStyle, 27 + required TextStyle? linkStyle, 28 + void Function(String did)? onMentionTap, 29 + void Function(String url)? onLinkTap, 30 + void Function(String tag)? onTagTap, 31 + }) { 32 + if (facets == null || facets.isEmpty) { 33 + return [TextSpan(text: text, style: defaultStyle)]; 34 + } 35 + 36 + // Build a list of all ranges (start, end, type, data) 37 + final List<FacetRange> ranges = facets.map((facet) { 38 + final feature = facet['features']?[0] ?? {}; 39 + final type = feature['\$type'] ?? feature['type']; 40 + return FacetRange( 41 + start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 42 + end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 43 + type: type, 44 + data: feature, 45 + ); 46 + }).toList(); 47 + 48 + // Sort ranges by the length of their display text (longest first) to avoid overlap issues 49 + ranges.sort((a, b) { 50 + int aLength = a.end - a.start; 51 + int bLength = b.end - b.start; 52 + 53 + // For links, use the length of the text that will actually be found 54 + if (a.type?.contains('link') == true && a.data['uri'] != null) { 55 + final uri = a.data['uri'] as String; 56 + final possibleTexts = [_extractDisplayTextFromUri(uri), _extractDomainOnly(uri), uri]; 57 + // Use the longest text that exists in the original text 58 + for (final testText in possibleTexts) { 59 + if (text.contains(testText)) { 60 + aLength = testText.length; 61 + break; 62 + } 63 + } 64 + } 65 + 66 + if (b.type?.contains('link') == true && b.data['uri'] != null) { 67 + final uri = b.data['uri'] as String; 68 + final possibleTexts = [_extractDisplayTextFromUri(uri), _extractDomainOnly(uri), uri]; 69 + // Use the longest text that exists in the original text 70 + for (final testText in possibleTexts) { 71 + if (text.contains(testText)) { 72 + bLength = testText.length; 73 + break; 74 + } 75 + } 76 + } 77 + 78 + // Sort by length descending, then by start position ascending 79 + final lengthComparison = bLength.compareTo(aLength); 80 + return lengthComparison != 0 ? lengthComparison : a.start.compareTo(b.start); 81 + }); 82 + 83 + final List<ProcessedSpan> processedSpans = <ProcessedSpan>[]; 84 + final Set<int> usedPositions = <int>{}; // Track which character positions are already used 85 + 86 + for (final range in ranges) { 87 + // For links, we need to find the actual text in the original text 88 + // since the facet positions might be based on the full URL with protocol 89 + String? actualContent; 90 + int actualStart = range.start; 91 + int actualEnd = range.end; 92 + 93 + if (range.type?.contains('link') == true && range.data['uri'] != null) { 94 + final uri = range.data['uri'] as String; 95 + 96 + // First, try to use the exact facet positions if they seem valid 97 + if (range.start >= 0 && range.end <= text.length && range.start < range.end) { 98 + final facetText = text.substring(range.start, range.end); 99 + 100 + // Check if the facet text matches any of our expected URL formats 101 + final possibleTexts = [ 102 + _extractDisplayTextFromUri(uri), // Full URL with protocol 103 + _extractDomainOnly(uri), // Just the domain 104 + uri, // Original URI as-is 105 + ]; 106 + 107 + bool facetTextMatches = possibleTexts.any( 108 + (possible) => 109 + facetText == possible || 110 + facetText.contains(possible) || 111 + possible.contains(facetText), 112 + ); 113 + 114 + if (facetTextMatches) { 115 + // Check if this range overlaps with used positions 116 + bool overlaps = false; 117 + for (int i = range.start; i < range.end; i++) { 118 + if (usedPositions.contains(i)) { 119 + overlaps = true; 120 + break; 121 + } 122 + } 123 + 124 + if (!overlaps) { 125 + actualStart = range.start; 126 + actualEnd = range.end; 127 + actualContent = 128 + facetText; // Use exactly what's in the original text at facet position 129 + 130 + // Mark these positions as used 131 + for (int i = actualStart; i < actualEnd; i++) { 132 + usedPositions.add(i); 133 + } 134 + } 135 + } 136 + } 137 + 138 + // If facet positions didn't work, fall back to searching 139 + if (actualContent == null) { 140 + final possibleTexts = [ 141 + _extractDisplayTextFromUri(uri), // Full URL with protocol 142 + _extractDomainOnly(uri), // Just the domain 143 + uri, // Original URI as-is 144 + ]; 145 + 146 + int searchIndex = 0; 147 + bool foundValidMatch = false; 148 + 149 + // Try each possible text representation 150 + for (final searchText in possibleTexts) { 151 + searchIndex = 0; 152 + while (!foundValidMatch) { 153 + final globalIndex = text.indexOf(searchText, searchIndex); 154 + if (globalIndex == -1) break; 155 + 156 + // Check if this range overlaps with any used positions 157 + bool overlaps = false; 158 + for (int i = globalIndex; i < globalIndex + searchText.length; i++) { 159 + if (usedPositions.contains(i)) { 160 + overlaps = true; 161 + break; 162 + } 163 + } 164 + 165 + if (!overlaps) { 166 + actualStart = globalIndex; 167 + actualEnd = globalIndex + searchText.length; 168 + actualContent = searchText; // Use exactly what we found in the text 169 + foundValidMatch = true; 170 + 171 + // Mark these positions as used 172 + for (int i = actualStart; i < actualEnd; i++) { 173 + usedPositions.add(i); 174 + } 175 + break; 176 + } else { 177 + searchIndex = globalIndex + 1; 178 + } 179 + } 180 + if (foundValidMatch) break; 181 + } 182 + } 183 + } 184 + 185 + // Handle other facet types that might have similar issues 186 + if (actualContent == null) { 187 + // Verify the range is within bounds 188 + if (range.start >= 0 && range.end <= text.length && range.start < range.end) { 189 + actualContent = text.substring(range.start, range.end); 190 + actualStart = range.start; 191 + actualEnd = range.end; 192 + 193 + // Check if this overlaps with used positions 194 + bool overlaps = false; 195 + for (int i = actualStart; i < actualEnd; i++) { 196 + if (usedPositions.contains(i)) { 197 + overlaps = true; 198 + break; 199 + } 200 + } 201 + 202 + if (!overlaps) { 203 + // Mark these positions as used 204 + for (int i = actualStart; i < actualEnd; i++) { 205 + usedPositions.add(i); 206 + } 207 + } else { 208 + // Skip overlapping ranges 209 + actualContent = null; 210 + } 211 + } else { 212 + // Skip invalid ranges 213 + continue; 214 + } 215 + } 216 + 217 + if (actualContent != null) { 218 + TextSpan span; 219 + if (range.type?.contains('mention') == true && range.data['did'] != null) { 220 + span = TextSpan( 221 + text: actualContent, 222 + style: linkStyle, 223 + recognizer: TapGestureRecognizer() 224 + ..onTap = onMentionTap != null ? () => onMentionTap(range.data['did']) : null, 225 + ); 226 + } else if (range.type?.contains('link') == true && range.data['uri'] != null) { 227 + span = TextSpan( 228 + text: actualContent, 229 + style: linkStyle, 230 + recognizer: TapGestureRecognizer() 231 + ..onTap = onLinkTap != null ? () => onLinkTap(range.data['uri']) : null, 232 + ); 233 + } else if (range.type?.contains('tag') == true && range.data['tag'] != null) { 234 + span = TextSpan( 235 + text: '#${range.data['tag']}', 236 + style: linkStyle, 237 + recognizer: TapGestureRecognizer() 238 + ..onTap = onTagTap != null ? () => onTagTap(range.data['tag']) : null, 239 + ); 240 + } else { 241 + span = TextSpan(text: actualContent, style: defaultStyle); 242 + } 243 + 244 + processedSpans.add(ProcessedSpan(start: actualStart, end: actualEnd, span: span)); 245 + } 246 + } 247 + 248 + // Sort processed spans by position and build final spans list 249 + processedSpans.sort((a, b) => a.start.compareTo(b.start)); 250 + int pos = 0; 251 + final spans = <TextSpan>[]; 252 + 253 + for (final processedSpan in processedSpans) { 254 + if (processedSpan.start > pos) { 255 + spans.add(TextSpan(text: text.substring(pos, processedSpan.start), style: defaultStyle)); 256 + } 257 + spans.add(processedSpan.span); 258 + pos = processedSpan.end; 259 + } 260 + 261 + if (pos < text.length) { 262 + spans.add(TextSpan(text: text.substring(pos), style: defaultStyle)); 263 + } 264 + 265 + return spans; 266 + } 267 + 268 + /// Extracts the display text from a URI (keeps protocol and domain, removes path) 269 + static String _extractDisplayTextFromUri(String uri) { 270 + // Find the first slash after the protocol to remove the path 271 + String protocolAndDomain = uri; 272 + if (uri.startsWith('https://')) { 273 + final pathIndex = uri.indexOf('/', 8); // Start search after "https://" 274 + if (pathIndex != -1) { 275 + protocolAndDomain = uri.substring(0, pathIndex); 276 + } 277 + } else if (uri.startsWith('http://')) { 278 + final pathIndex = uri.indexOf('/', 7); // Start search after "http://" 279 + if (pathIndex != -1) { 280 + protocolAndDomain = uri.substring(0, pathIndex); 281 + } 282 + } else { 283 + // For URIs without protocol, just remove the path 284 + final slashIndex = uri.indexOf('/'); 285 + if (slashIndex != -1) { 286 + protocolAndDomain = uri.substring(0, slashIndex); 287 + } 288 + } 289 + 290 + return protocolAndDomain; 291 + } 292 + 293 + /// Extracts just the domain part from a URI (removes protocol and path) 294 + static String _extractDomainOnly(String uri) { 295 + String domain = uri; 296 + if (uri.startsWith('https://')) { 297 + domain = uri.substring(8); 298 + } else if (uri.startsWith('http://')) { 299 + domain = uri.substring(7); 300 + } 301 + 302 + // Remove path 303 + final slashIndex = domain.indexOf('/'); 304 + if (slashIndex != -1) { 305 + domain = domain.substring(0, slashIndex); 306 + } 307 + 308 + return domain; 309 + } 310 + }
+12 -14
lib/widgets/add_comment_sheet.dart
··· 5 5 import 'package:grain/app_icons.dart'; 6 6 import 'package:grain/widgets/app_image.dart'; 7 7 import 'package:grain/widgets/gallery_preview.dart'; 8 + import 'package:grain/widgets/faceted_text_field.dart'; 8 9 9 10 Future<void> showAddCommentSheet( 10 11 BuildContext context, { ··· 185 186 child: Column( 186 187 crossAxisAlignment: CrossAxisAlignment.start, 187 188 children: [ 188 - Row( 189 + Column( 190 + crossAxisAlignment: CrossAxisAlignment.start, 189 191 children: [ 190 192 Text( 191 193 creator is Map ··· 199 201 ? (creator['handle'] ?? '') 200 202 : (creator.handle ?? '')) 201 203 .isNotEmpty) ...[ 202 - const SizedBox(width: 8), 204 + const SizedBox(height: 1), 203 205 Text( 204 206 '@${creator is Map ? creator['handle'] : creator.handle}', 205 207 style: theme.textTheme.bodySmall?.copyWith( ··· 315 317 ), 316 318 // Text input 317 319 Expanded( 318 - child: TextField( 319 - controller: widget.controller, 320 - focusNode: _focusNode, 321 - maxLines: 6, 322 - minLines: 2, 323 - style: theme.textTheme.bodyMedium, 324 - decoration: InputDecoration( 320 + child: Padding( 321 + padding: const EdgeInsets.only(left: 10), 322 + child: FacetedTextField( 323 + controller: widget.controller, 324 + maxLines: 6, 325 + enabled: true, 326 + keyboardType: TextInputType.multiline, 325 327 hintText: 'Add a comment', 326 - hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 327 - border: InputBorder.none, 328 - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 329 - isDense: true, 330 - filled: false, 328 + // The FacetedTextField handles its own style and padding internally 331 329 ), 332 330 ), 333 331 ),
+15
lib/widgets/app_drawer.dart
··· 2 2 import 'package:grain/api.dart'; 3 3 import 'package:grain/app_icons.dart'; 4 4 import 'package:grain/screens/log_page.dart'; 5 + import 'package:grain/screens/photo_library_page.dart'; 5 6 import 'package:grain/widgets/app_version_text.dart'; 6 7 7 8 class AppDrawer extends StatelessWidget { ··· 176 177 onTap: () { 177 178 Navigator.pop(context); 178 179 onProfile(); 180 + }, 181 + ), 182 + ListTile( 183 + leading: Icon( 184 + AppIcons.photoLibrary, 185 + size: 18, 186 + color: activeIndex == 4 ? theme.colorScheme.primary : theme.iconTheme.color, 187 + ), 188 + title: const Text('Photo Library'), 189 + onTap: () { 190 + Navigator.pop(context); 191 + Navigator.of( 192 + context, 193 + ).push(MaterialPageRoute(builder: (context) => const PhotoLibraryPage())); 179 194 }, 180 195 ), 181 196 ListTile(
+71 -25
lib/widgets/edit_profile_sheet.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter/services.dart'; 6 6 import 'package:grain/app_icons.dart'; 7 + import 'package:grain/widgets/faceted_text_field.dart'; 7 8 import 'package:grain/widgets/plain_text_field.dart'; 8 9 import 'package:image_picker/image_picker.dart'; 9 10 ··· 61 62 late TextEditingController _descriptionController; 62 63 XFile? _selectedAvatar; 63 64 bool _saving = false; 64 - bool _hasChanged = false; 65 + static const int maxDisplayNameGraphemes = 64; 66 + static const int maxDescriptionGraphemes = 256; 65 67 66 68 @override 67 69 void initState() { ··· 69 71 _displayNameController = TextEditingController(text: widget.initialDisplayName ?? ''); 70 72 _descriptionController = TextEditingController(text: widget.initialDescription ?? ''); 71 73 _displayNameController.addListener(_onInputChanged); 72 - _descriptionController.addListener(_onInputChanged); 74 + _descriptionController.addListener(_onDescriptionChanged); 75 + } 76 + 77 + void _onDescriptionChanged() { 78 + setState(() {}); // For character count 73 79 } 74 80 75 81 void _onInputChanged() { 76 - final displayName = _displayNameController.text.trim(); 77 - final initialDisplayName = widget.initialDisplayName ?? ''; 78 - final displayNameChanged = displayName != initialDisplayName; 79 - final descriptionChanged = 80 - _descriptionController.text.trim() != (widget.initialDescription ?? ''); 81 - final avatarChanged = _selectedAvatar != null; 82 - // Only allow Save if displayName is not empty and at least one field changed 83 - final changed = 84 - (displayNameChanged || descriptionChanged || avatarChanged) && displayName.isNotEmpty; 85 - if (_hasChanged != changed) { 86 - setState(() { 87 - _hasChanged = changed; 88 - }); 89 - } 82 + setState(() { 83 + // Trigger rebuild to update character counts 84 + }); 90 85 } 91 86 92 87 @override 93 88 void dispose() { 94 - _displayNameController.removeListener(_onInputChanged); 95 - _descriptionController.removeListener(_onInputChanged); 96 89 _displayNameController.dispose(); 97 90 _descriptionController.dispose(); 98 91 super.dispose(); ··· 104 97 if (picked != null) { 105 98 setState(() { 106 99 _selectedAvatar = picked; 107 - _onInputChanged(); 108 100 }); 109 101 } 110 102 } ··· 113 105 Widget build(BuildContext context) { 114 106 final theme = Theme.of(context); 115 107 final avatarRadius = 44.0; 108 + final displayNameGraphemes = _displayNameController.text.characters.length; 109 + final descriptionGraphemes = _descriptionController.text.characters.length; 116 110 return CupertinoPageScaffold( 117 111 backgroundColor: theme.colorScheme.surface, 118 112 navigationBar: CupertinoNavigationBar( ··· 132 126 ), 133 127 trailing: CupertinoButton( 134 128 padding: EdgeInsets.zero, 135 - onPressed: (!_hasChanged || _saving) 129 + onPressed: _saving 136 130 ? null 137 131 : () async { 132 + if (displayNameGraphemes > maxDisplayNameGraphemes || 133 + descriptionGraphemes > maxDescriptionGraphemes) { 134 + await showDialog( 135 + context: context, 136 + builder: (context) => AlertDialog( 137 + title: const Text('Character Limit Exceeded'), 138 + content: Text( 139 + displayNameGraphemes > maxDisplayNameGraphemes 140 + ? 'Display Name must be $maxDisplayNameGraphemes characters or fewer.' 141 + : 'Description must be $maxDescriptionGraphemes characters or fewer.', 142 + ), 143 + actions: [ 144 + TextButton( 145 + child: const Text('OK'), 146 + onPressed: () => Navigator.of(context).pop(), 147 + ), 148 + ], 149 + ), 150 + ); 151 + return; 152 + } 138 153 if (widget.onSave != null) { 139 154 setState(() { 140 155 _saving = true; ··· 155 170 Text( 156 171 'Save', 157 172 style: TextStyle( 158 - color: (!_hasChanged || _saving) 159 - ? theme.disabledColor 160 - : theme.colorScheme.primary, 173 + color: _saving ? theme.disabledColor : theme.colorScheme.primary, 161 174 fontWeight: FontWeight.w600, 162 175 ), 163 176 ), ··· 233 246 controller: _displayNameController, 234 247 maxLines: 1, 235 248 ), 249 + Padding( 250 + padding: const EdgeInsets.only(top: 4), 251 + child: Row( 252 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 253 + children: [ 254 + const SizedBox(), 255 + Text( 256 + '$displayNameGraphemes/$maxDisplayNameGraphemes', 257 + style: theme.textTheme.bodySmall?.copyWith( 258 + color: displayNameGraphemes > maxDisplayNameGraphemes 259 + ? theme.colorScheme.error 260 + : theme.textTheme.bodySmall?.color, 261 + ), 262 + ), 263 + ], 264 + ), 265 + ), 236 266 const SizedBox(height: 12), 237 - PlainTextField( 267 + FacetedTextField( 238 268 label: 'Description', 239 269 controller: _descriptionController, 240 270 maxLines: 6, 241 271 ), 272 + Padding( 273 + padding: const EdgeInsets.only(top: 4), 274 + child: Row( 275 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 276 + children: [ 277 + const SizedBox(), 278 + Text( 279 + '$descriptionGraphemes/$maxDescriptionGraphemes', 280 + style: theme.textTheme.bodySmall?.copyWith( 281 + color: descriptionGraphemes > maxDescriptionGraphemes 282 + ? theme.colorScheme.error 283 + : theme.textTheme.bodySmall?.color, 284 + ), 285 + ), 286 + ], 287 + ), 288 + ), 242 289 ], 243 290 ), 244 291 ), 245 292 ), 246 - const SizedBox(height: 24), 247 293 ], 248 294 ), 249 295 ),
+14 -63
lib/widgets/faceted_text.dart
··· 1 - import 'package:flutter/gestures.dart'; 2 1 import 'package:flutter/material.dart'; 2 + 3 + import '../utils/facet_utils.dart'; 3 4 4 5 class FacetedText extends StatelessWidget { 5 6 final String text; ··· 32 33 fontWeight: FontWeight.w600, 33 34 decoration: TextDecoration.underline, 34 35 ); 36 + 35 37 if (facets == null || facets!.isEmpty) { 36 38 return Text(text, style: defaultStyle); 37 39 } 38 - // Build a list of all ranges (start, end, type, data) 39 - final List<_FacetRange> ranges = facets!.map((facet) { 40 - final feature = facet['features']?[0] ?? {}; 41 - final type = feature['\$type'] ?? feature['type']; 42 - return _FacetRange( 43 - start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 44 - end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 45 - type: type, 46 - data: feature, 47 - ); 48 - }).toList(); 49 - ranges.sort((a, b) => a.start.compareTo(b.start)); 50 - int pos = 0; 51 - final spans = <TextSpan>[]; 52 - for (final range in ranges) { 53 - if (range.start > pos) { 54 - spans.add(TextSpan(text: text.substring(pos, range.start), style: defaultStyle)); 55 - } 56 - final content = text.substring(range.start, range.end); 57 - if (range.type?.contains('mention') == true && range.data['did'] != null) { 58 - spans.add( 59 - TextSpan( 60 - text: content, 61 - style: defaultLinkStyle, 62 - recognizer: TapGestureRecognizer() 63 - ..onTap = onMentionTap != null ? () => onMentionTap!(range.data['did']) : null, 64 - ), 65 - ); 66 - } else if (range.type?.contains('link') == true && range.data['uri'] != null) { 67 - spans.add( 68 - TextSpan( 69 - text: content, 70 - style: defaultLinkStyle, 71 - recognizer: TapGestureRecognizer() 72 - ..onTap = onLinkTap != null ? () => onLinkTap!(range.data['uri']) : null, 73 - ), 74 - ); 75 - } else if (range.type?.contains('tag') == true && range.data['tag'] != null) { 76 - spans.add( 77 - TextSpan( 78 - text: '#${range.data['tag']}', 79 - style: defaultLinkStyle, 80 - recognizer: TapGestureRecognizer() 81 - ..onTap = onTagTap != null ? () => onTagTap!(range.data['tag']) : null, 82 - ), 83 - ); 84 - } else { 85 - spans.add(TextSpan(text: content, style: defaultStyle)); 86 - } 87 - pos = range.end; 88 - } 89 - if (pos < text.length) { 90 - spans.add(TextSpan(text: text.substring(pos), style: defaultStyle)); 91 - } 40 + 41 + final spans = FacetUtils.processFacets( 42 + text: text, 43 + facets: facets, 44 + defaultStyle: defaultStyle, 45 + linkStyle: defaultLinkStyle, 46 + onMentionTap: onMentionTap, 47 + onLinkTap: onLinkTap, 48 + onTagTap: onTagTap, 49 + ); 50 + 92 51 return RichText(text: TextSpan(children: spans)); 93 52 } 94 53 } 95 - 96 - class _FacetRange { 97 - final int start; 98 - final int end; 99 - final String? type; 100 - final Map<String, dynamic> data; 101 - _FacetRange({required this.start, required this.end, required this.type, required this.data}); 102 - }
+473
lib/widgets/faceted_text_field.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bluesky_text/bluesky_text.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + 7 + import '../models/profile.dart'; 8 + import '../providers/actor_search_provider.dart'; 9 + import '../utils/facet_utils.dart'; 10 + 11 + class FacetedTextField extends ConsumerStatefulWidget { 12 + final String? label; 13 + final TextEditingController controller; 14 + final int maxLines; 15 + final bool enabled; 16 + final TextInputType? keyboardType; 17 + final String? hintText; 18 + final void Function(String)? onChanged; 19 + final Widget? prefixIcon; 20 + final Widget? suffixIcon; 21 + final List<Map<String, dynamic>>? facets; 22 + 23 + const FacetedTextField({ 24 + super.key, 25 + this.label, 26 + required this.controller, 27 + this.maxLines = 1, 28 + this.enabled = true, 29 + this.keyboardType, 30 + this.hintText, 31 + this.onChanged, 32 + this.prefixIcon, 33 + this.suffixIcon, 34 + this.facets, 35 + }); 36 + 37 + @override 38 + ConsumerState<FacetedTextField> createState() => _FacetedTextFieldState(); 39 + } 40 + 41 + class _FacetedTextFieldState extends ConsumerState<FacetedTextField> { 42 + // Track which handles have been inserted via overlay selection 43 + final Set<String> _insertedHandles = {}; 44 + OverlayEntry? _overlayEntry; 45 + final GlobalKey _fieldKey = GlobalKey(); 46 + List<Profile> _actorResults = []; 47 + Timer? _debounceTimer; 48 + 49 + @override 50 + void initState() { 51 + super.initState(); 52 + widget.controller.addListener(_onTextChanged); 53 + } 54 + 55 + @override 56 + void dispose() { 57 + widget.controller.removeListener(_onTextChanged); 58 + _debounceTimer?.cancel(); 59 + _removeOverlay(); 60 + super.dispose(); 61 + } 62 + 63 + void _onTextChanged() async { 64 + final text = widget.controller.text; 65 + final selection = widget.controller.selection; 66 + final cursorPos = selection.baseOffset; 67 + if (cursorPos < 0) { 68 + _removeOverlay(); 69 + return; 70 + } 71 + // If the last character typed is a space, always close overlay 72 + if (cursorPos > 0 && text[cursorPos - 1] == ' ') { 73 + _removeOverlay(); 74 + return; 75 + } 76 + // Find the @mention match that contains the cursor 77 + final regex = RegExp(r'@([\w.]+)'); 78 + final matches = regex.allMatches(text); 79 + String? query; 80 + for (final match in matches) { 81 + final start = match.start; 82 + final end = match.end; 83 + if (cursorPos > start && cursorPos <= end) { 84 + query = match.group(1); 85 + break; 86 + } 87 + } 88 + if (query != null && query.isNotEmpty) { 89 + _debounceTimer?.cancel(); 90 + _debounceTimer = Timer(const Duration(milliseconds: 500), () async { 91 + final results = await ref.read(actorSearchProvider.notifier).search(query!); 92 + if (mounted) { 93 + setState(() { 94 + _actorResults = results; 95 + }); 96 + _showOverlay(); 97 + } 98 + }); 99 + return; 100 + } 101 + _debounceTimer?.cancel(); 102 + _removeOverlay(); 103 + } 104 + 105 + void _showOverlay() { 106 + WidgetsBinding.instance.addPostFrameCallback((_) { 107 + _removeOverlay(); 108 + final overlay = Overlay.of(context); 109 + final caretOffset = _getCaretPosition(); 110 + if (caretOffset == null) return; 111 + 112 + // Show only the first 5 results, no scroll, use simple rows 113 + final double rowHeight = 44.0; 114 + final int maxItems = 5; 115 + final resultsToShow = _actorResults.take(maxItems).toList(); 116 + final double overlayHeight = resultsToShow.length * rowHeight; 117 + final double overlayWidth = 300.0; 118 + 119 + // Get screen size 120 + final mediaQuery = MediaQuery.of(context); 121 + final screenWidth = mediaQuery.size.width; 122 + 123 + // Default to left of caret, but if it would overflow, switch to right 124 + double left = caretOffset.dx; 125 + if (left + overlayWidth > screenWidth - 8) { 126 + // Try to align right edge of overlay with caret, but don't go off left edge 127 + left = (caretOffset.dx - overlayWidth).clamp(8.0, screenWidth - overlayWidth - 8.0); 128 + } 129 + 130 + _overlayEntry = OverlayEntry( 131 + builder: (context) => Positioned( 132 + left: left, 133 + top: caretOffset.dy, 134 + width: overlayWidth, 135 + height: overlayHeight, 136 + child: Material( 137 + elevation: 4, 138 + child: Column( 139 + mainAxisSize: MainAxisSize.min, 140 + children: resultsToShow.map((actor) { 141 + return Material( 142 + color: Colors.transparent, 143 + child: InkWell( 144 + onTap: () => _insertActor(actor.handle), 145 + child: Container( 146 + height: rowHeight, 147 + width: double.infinity, 148 + alignment: Alignment.centerLeft, 149 + padding: const EdgeInsets.symmetric(horizontal: 12.0), 150 + child: Row( 151 + children: [ 152 + if (actor.avatar != null && actor.avatar!.isNotEmpty) 153 + CircleAvatar(radius: 16, backgroundImage: NetworkImage(actor.avatar!)) 154 + else 155 + CircleAvatar(radius: 16, child: Icon(Icons.person, size: 16)), 156 + const SizedBox(width: 12), 157 + Expanded( 158 + child: Text( 159 + actor.displayName ?? actor.handle, 160 + style: Theme.of(context).textTheme.bodyMedium, 161 + overflow: TextOverflow.ellipsis, 162 + ), 163 + ), 164 + const SizedBox(width: 8), 165 + Text( 166 + '@${actor.handle}', 167 + style: Theme.of( 168 + context, 169 + ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), 170 + overflow: TextOverflow.ellipsis, 171 + ), 172 + ], 173 + ), 174 + ), 175 + ), 176 + ); 177 + }).toList(), 178 + ), 179 + ), 180 + ), 181 + ); 182 + overlay.insert(_overlayEntry!); 183 + }); 184 + } 185 + 186 + void _removeOverlay() { 187 + if (_overlayEntry != null) { 188 + _overlayEntry?.remove(); 189 + _overlayEntry = null; 190 + } 191 + } 192 + 193 + Offset? _getCaretPosition() { 194 + final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; 195 + if (renderBox == null) return null; 196 + 197 + final controller = widget.controller; 198 + final selection = controller.selection; 199 + if (!selection.isValid) return null; 200 + 201 + // Get the text up to the caret 202 + final text = controller.text.substring(0, selection.baseOffset); 203 + final textStyle = 204 + Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15) ?? 205 + const TextStyle(fontSize: 15); 206 + final textPainter = TextPainter( 207 + text: TextSpan(text: text, style: textStyle), 208 + textDirection: TextDirection.ltr, 209 + maxLines: widget.maxLines, 210 + ); 211 + textPainter.layout(minWidth: 0, maxWidth: renderBox.size.width); 212 + 213 + final caretOffset = textPainter.getOffsetForCaret(TextPosition(offset: text.length), Rect.zero); 214 + 215 + // Convert caret offset to global coordinates 216 + final fieldOffset = renderBox.localToGlobal(Offset.zero); 217 + // Add vertical padding to position below the caret 218 + return fieldOffset + Offset(caretOffset.dx, caretOffset.dy + textPainter.preferredLineHeight); 219 + } 220 + 221 + void _insertActor(String actorName) { 222 + final text = widget.controller.text; 223 + final selection = widget.controller.selection; 224 + final cursorPos = selection.baseOffset; 225 + // Find the @mention match that contains the cursor (not just before it) 226 + final regex = RegExp(r'@([\w.]+)'); 227 + final matches = regex.allMatches(text); 228 + Match? matchToReplace; 229 + for (final match in matches) { 230 + if (cursorPos > match.start && cursorPos <= match.end) { 231 + matchToReplace = match; 232 + break; 233 + } 234 + } 235 + if (matchToReplace != null) { 236 + final start = matchToReplace.start; 237 + final end = matchToReplace.end; 238 + final newText = text.replaceRange(start, end, '@$actorName '); 239 + setState(() { 240 + _insertedHandles.add(actorName); 241 + }); 242 + widget.controller.value = TextEditingValue( 243 + text: newText, 244 + selection: TextSelection.collapsed(offset: start + actorName.length + 2), 245 + ); 246 + } 247 + _removeOverlay(); 248 + } 249 + 250 + @override 251 + Widget build(BuildContext context) { 252 + final theme = Theme.of(context); 253 + return Column( 254 + crossAxisAlignment: CrossAxisAlignment.start, 255 + children: [ 256 + if (widget.label != null && widget.label!.isNotEmpty) ...[ 257 + Text( 258 + widget.label!, 259 + style: theme.textTheme.bodyMedium?.copyWith( 260 + fontWeight: FontWeight.w500, 261 + color: theme.colorScheme.onSurface, 262 + ), 263 + ), 264 + const SizedBox(height: 6), 265 + ], 266 + Container( 267 + decoration: BoxDecoration( 268 + color: theme.brightness == Brightness.dark ? Colors.grey[850] : Colors.grey[300], 269 + borderRadius: BorderRadius.circular(8), 270 + ), 271 + child: Focus( 272 + child: Builder( 273 + builder: (context) { 274 + final isFocused = Focus.of(context).hasFocus; 275 + return Stack( 276 + children: [ 277 + _MentionHighlightTextField( 278 + key: _fieldKey, 279 + controller: widget.controller, 280 + maxLines: widget.maxLines, 281 + enabled: widget.enabled, 282 + keyboardType: widget.keyboardType, 283 + onChanged: widget.onChanged, 284 + hintText: widget.hintText, 285 + prefixIcon: widget.prefixIcon, 286 + suffixIcon: widget.suffixIcon, 287 + insertedHandles: _insertedHandles, 288 + facets: widget.facets, 289 + ), 290 + // Border overlay 291 + Positioned.fill( 292 + child: IgnorePointer( 293 + child: AnimatedContainer( 294 + duration: const Duration(milliseconds: 150), 295 + decoration: BoxDecoration( 296 + border: Border.all( 297 + color: isFocused ? theme.colorScheme.primary : theme.dividerColor, 298 + width: isFocused ? 2 : 0, 299 + ), 300 + borderRadius: BorderRadius.circular(8), 301 + ), 302 + ), 303 + ), 304 + ), 305 + ], 306 + ); 307 + }, 308 + ), 309 + ), 310 + ), 311 + ], 312 + ); 313 + } 314 + } 315 + 316 + class _MentionHighlightTextField extends StatefulWidget { 317 + final Set<String>? insertedHandles; 318 + final TextEditingController controller; 319 + final int maxLines; 320 + final bool enabled; 321 + final TextInputType? keyboardType; 322 + final String? hintText; 323 + final void Function(String)? onChanged; 324 + final Widget? prefixIcon; 325 + final Widget? suffixIcon; 326 + final List<Map<String, dynamic>>? facets; 327 + 328 + const _MentionHighlightTextField({ 329 + super.key, 330 + required this.controller, 331 + required this.maxLines, 332 + required this.enabled, 333 + this.keyboardType, 334 + this.hintText, 335 + this.onChanged, 336 + this.prefixIcon, 337 + this.suffixIcon, 338 + this.insertedHandles, 339 + this.facets, 340 + }); 341 + 342 + @override 343 + State<_MentionHighlightTextField> createState() => _MentionHighlightTextFieldState(); 344 + } 345 + 346 + class _MentionHighlightTextFieldState extends State<_MentionHighlightTextField> { 347 + final ScrollController _richTextScrollController = ScrollController(); 348 + final ScrollController _textFieldScrollController = ScrollController(); 349 + 350 + void _onMentionTap(String did) { 351 + // Show overlay for this mention (simulate as if user is typing @mention) 352 + final parent = context.findAncestorStateOfType<_FacetedTextFieldState>(); 353 + if (parent != null) { 354 + parent._showOverlay(); 355 + } 356 + } 357 + 358 + List<Map<String, dynamic>> _parsedFacets = []; 359 + Timer? _facetDebounce; 360 + 361 + @override 362 + void initState() { 363 + super.initState(); 364 + _parseFacets(); 365 + widget.controller.addListener(_parseFacets); 366 + 367 + // Sync scroll controllers 368 + _textFieldScrollController.addListener(() { 369 + if (_richTextScrollController.hasClients && _textFieldScrollController.hasClients) { 370 + _richTextScrollController.jumpTo(_textFieldScrollController.offset); 371 + } 372 + }); 373 + } 374 + 375 + @override 376 + void dispose() { 377 + widget.controller.removeListener(_parseFacets); 378 + _facetDebounce?.cancel(); 379 + _richTextScrollController.dispose(); 380 + _textFieldScrollController.dispose(); 381 + super.dispose(); 382 + } 383 + 384 + void _parseFacets() { 385 + _facetDebounce?.cancel(); 386 + _facetDebounce = Timer(const Duration(milliseconds: 100), () async { 387 + final text = widget.controller.text; 388 + if (widget.facets != null && widget.facets!.isNotEmpty) { 389 + setState(() => _parsedFacets = widget.facets!); 390 + } else { 391 + try { 392 + final blueskyText = BlueskyText(text); 393 + final entities = blueskyText.entities; 394 + final facets = await entities.toFacets(); 395 + if (mounted) setState(() => _parsedFacets = List<Map<String, dynamic>>.from(facets)); 396 + } catch (_) { 397 + if (mounted) setState(() => _parsedFacets = []); 398 + } 399 + } 400 + }); 401 + } 402 + 403 + @override 404 + Widget build(BuildContext context) { 405 + final theme = Theme.of(context); 406 + final text = widget.controller.text; 407 + final baseStyle = theme.textTheme.bodyMedium?.copyWith(fontSize: 15); 408 + final linkStyle = baseStyle?.copyWith(color: theme.colorScheme.primary); 409 + 410 + // Use the same facet processing logic as FacetedText 411 + final spans = FacetUtils.processFacets( 412 + text: text, 413 + facets: _parsedFacets, 414 + defaultStyle: baseStyle, 415 + linkStyle: linkStyle, 416 + onMentionTap: _onMentionTap, 417 + onLinkTap: null, // No link tap in text field 418 + onTagTap: null, // No tag tap in text field 419 + ); 420 + return LayoutBuilder( 421 + builder: (context, constraints) { 422 + return SizedBox( 423 + width: double.infinity, // Make it full width 424 + height: widget.maxLines == 1 425 + ? null 426 + : (baseStyle?.fontSize ?? 15) * 1.4 * widget.maxLines + 427 + 24, // Line height * maxLines + padding 428 + child: Stack( 429 + children: [ 430 + // RichText for highlight wrapped in SingleChildScrollView 431 + SingleChildScrollView( 432 + controller: _richTextScrollController, 433 + physics: const NeverScrollableScrollPhysics(), // Disable direct interaction 434 + child: Padding( 435 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 436 + child: RichText( 437 + text: TextSpan(children: spans), 438 + maxLines: null, // Allow unlimited lines for scrolling 439 + overflow: TextOverflow.visible, 440 + ), 441 + ), 442 + ), 443 + // Editable TextField for input, but with transparent text so only RichText is visible 444 + Positioned.fill( 445 + child: TextField( 446 + controller: widget.controller, 447 + scrollController: _textFieldScrollController, 448 + maxLines: null, // Allow unlimited lines for scrolling 449 + enabled: widget.enabled, 450 + keyboardType: widget.keyboardType, 451 + onChanged: widget.onChanged, 452 + style: baseStyle?.copyWith(color: const Color(0x01000000)), 453 + cursorColor: theme.colorScheme.primary, 454 + showCursor: true, 455 + enableInteractiveSelection: true, 456 + decoration: InputDecoration( 457 + hintText: widget.hintText, 458 + hintStyle: baseStyle?.copyWith(color: theme.hintColor), 459 + border: InputBorder.none, 460 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 461 + isDense: true, 462 + prefixIcon: widget.prefixIcon, 463 + suffixIcon: widget.suffixIcon, 464 + ), 465 + ), 466 + ), 467 + ], 468 + ), 469 + ); 470 + }, 471 + ); 472 + } 473 + }
+20 -1
lib/widgets/gallery_photo_view.dart
··· 128 128 final gallery = widget.gallery; 129 129 final subject = gallery?.uri; 130 130 final focus = photo.uri; 131 - if (subject == null || focus == null) { 131 + if (subject == null) { 132 132 return; 133 133 } 134 134 // Use the provider's createComment method ··· 165 165 }, 166 166 ), 167 167 ], 168 + ), 169 + ), 170 + ), 171 + if (!widget.showAddCommentButton && photo.exif != null) 172 + SafeArea( 173 + top: false, 174 + child: Padding( 175 + padding: const EdgeInsets.all(16), 176 + child: Align( 177 + alignment: Alignment.centerRight, 178 + child: IconButton( 179 + icon: Icon(Icons.camera_alt, color: Colors.white), 180 + onPressed: () { 181 + showDialog( 182 + context: context, 183 + builder: (context) => PhotoExifDialog(exif: photo.exif!), 184 + ); 185 + }, 186 + ), 168 187 ), 169 188 ), 170 189 ),
+44 -23
lib/widgets/timeline_item.dart
··· 8 8 import 'package:grain/widgets/faceted_text.dart'; 9 9 import 'package:grain/widgets/gallery_action_buttons.dart'; 10 10 import 'package:grain/widgets/gallery_preview.dart'; 11 + import 'package:url_launcher/url_launcher.dart'; 11 12 12 13 import '../providers/gallery_cache_provider.dart'; 13 14 import '../screens/gallery_page.dart'; ··· 73 74 mainAxisAlignment: MainAxisAlignment.spaceBetween, 74 75 children: [ 75 76 Flexible( 76 - child: Text.rich( 77 - TextSpan( 78 - children: [ 79 - if (actor?.displayName?.isNotEmpty ?? false) 80 - TextSpan( 81 - text: actor!.displayName ?? '', 82 - style: theme.textTheme.titleMedium?.copyWith( 83 - fontWeight: FontWeight.w600, 84 - fontSize: 16, 77 + child: GestureDetector( 78 + onTap: 79 + onProfileTap ?? 80 + () { 81 + if (actor?.did != null) { 82 + Navigator.of(context).push( 83 + MaterialPageRoute( 84 + builder: (context) => 85 + ProfilePage(did: actor!.did, showAppBar: true), 86 + ), 87 + ); 88 + } 89 + }, 90 + child: Text.rich( 91 + TextSpan( 92 + children: [ 93 + if (actor?.displayName?.isNotEmpty ?? false) 94 + TextSpan( 95 + text: actor!.displayName ?? '', 96 + style: theme.textTheme.titleMedium?.copyWith( 97 + fontWeight: FontWeight.w600, 98 + fontSize: 16, 99 + ), 85 100 ), 86 - ), 87 - if (actor != null && actor.handle.isNotEmpty) 88 - TextSpan( 89 - text: (actor.displayName?.isNotEmpty ?? false) 90 - ? ' @${actor.handle}' 91 - : '@${actor.handle}', 92 - style: theme.textTheme.bodySmall?.copyWith( 93 - fontSize: 14, 94 - color: theme.colorScheme.onSurfaceVariant, 95 - fontWeight: FontWeight.normal, 101 + if (actor != null && actor.handle.isNotEmpty) 102 + TextSpan( 103 + text: (actor.displayName?.isNotEmpty ?? false) 104 + ? ' @${actor.handle}' 105 + : '@${actor.handle}', 106 + style: theme.textTheme.bodySmall?.copyWith( 107 + fontSize: 14, 108 + color: theme.colorScheme.onSurfaceVariant, 109 + fontWeight: FontWeight.normal, 110 + ), 96 111 ), 97 - ), 98 - ], 112 + ], 113 + ), 114 + overflow: TextOverflow.ellipsis, 115 + maxLines: 1, 99 116 ), 100 - overflow: TextOverflow.ellipsis, 101 - maxLines: 1, 102 117 ), 103 118 ), 104 119 Text( ··· 159 174 context, 160 175 MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)), 161 176 ), 177 + onLinkTap: (url) async { 178 + final uri = Uri.parse(url); 179 + if (!await launchUrl(uri)) { 180 + throw Exception('Could not launch $url'); 181 + } 182 + }, 162 183 ), 163 184 ), 164 185 const SizedBox(height: 8),
+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 + }
+6 -6
pubspec.lock
··· 149 149 dependency: transitive 150 150 description: 151 151 name: built_value 152 - sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" 152 + sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62" 153 153 url: "https://pub.dev" 154 154 source: hosted 155 - version: "8.10.1" 155 + version: "8.11.0" 156 156 cached_network_image: 157 157 dependency: "direct main" 158 158 description: ··· 309 309 dependency: transitive 310 310 description: 311 311 name: dart_style 312 - sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" 312 + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" 313 313 url: "https://pub.dev" 314 314 source: hosted 315 - version: "3.1.0" 315 + version: "3.1.1" 316 316 desktop_webview_window: 317 317 dependency: transitive 318 318 description: ··· 748 748 dependency: "direct main" 749 749 description: 750 750 name: logger 751 - sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" 751 + sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c" 752 752 url: "https://pub.dev" 753 753 source: hosted 754 - version: "2.6.0" 754 + version: "2.6.1" 755 755 logging: 756 756 dependency: transitive 757 757 description:
+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+17 19 + version: 1.0.0+24 20 20 21 21 environment: 22 22 sdk: ^3.8.1