Compare changes

Choose any two refs to compare.

-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 };
+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 {
+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 + }
+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(
-1
lib/widgets/edit_profile_sheet.dart
··· 290 290 ), 291 291 ), 292 292 ), 293 - const SizedBox(height: 24), 294 293 ], 295 294 ), 296 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 - }
+100 -133
lib/widgets/faceted_text_field.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:bluesky_text/bluesky_text.dart'; 4 - import 'package:flutter/gestures.dart'; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 6 8 7 import '../models/profile.dart'; 9 8 import '../providers/actor_search_provider.dart'; 10 - 11 - class _FacetRange { 12 - final int start; 13 - final int end; 14 - final String? type; 15 - final Map<String, dynamic> data; 16 - _FacetRange({required this.start, required this.end, required this.type, required this.data}); 17 - } 9 + import '../utils/facet_utils.dart'; 18 10 19 11 class FacetedTextField extends ConsumerStatefulWidget { 20 12 final String? label; ··· 146 138 child: Column( 147 139 mainAxisSize: MainAxisSize.min, 148 140 children: resultsToShow.map((actor) { 149 - return GestureDetector( 150 - onTap: () => _insertActor(actor.handle), 151 - child: Container( 152 - height: rowHeight, 153 - alignment: Alignment.centerLeft, 154 - padding: const EdgeInsets.symmetric(horizontal: 12.0), 155 - child: Row( 156 - children: [ 157 - if (actor.avatar != null && actor.avatar!.isNotEmpty) 158 - CircleAvatar(radius: 16, backgroundImage: NetworkImage(actor.avatar!)) 159 - else 160 - CircleAvatar(radius: 16, child: Icon(Icons.person, size: 16)), 161 - const SizedBox(width: 12), 162 - Expanded( 163 - child: Text( 164 - actor.displayName ?? actor.handle, 165 - style: Theme.of(context).textTheme.bodyMedium, 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]), 166 170 overflow: TextOverflow.ellipsis, 167 171 ), 168 - ), 169 - const SizedBox(width: 8), 170 - Text( 171 - '@${actor.handle}', 172 - style: Theme.of( 173 - context, 174 - ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), 175 - overflow: TextOverflow.ellipsis, 176 - ), 177 - ], 172 + ], 173 + ), 178 174 ), 179 175 ), 180 176 ); ··· 348 344 } 349 345 350 346 class _MentionHighlightTextFieldState extends State<_MentionHighlightTextField> { 347 + final ScrollController _richTextScrollController = ScrollController(); 348 + final ScrollController _textFieldScrollController = ScrollController(); 349 + 351 350 void _onMentionTap(String did) { 352 351 // Show overlay for this mention (simulate as if user is typing @mention) 353 352 final parent = context.findAncestorStateOfType<_FacetedTextFieldState>(); ··· 364 363 super.initState(); 365 364 _parseFacets(); 366 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 + }); 367 373 } 368 374 369 375 @override 370 376 void dispose() { 371 377 widget.controller.removeListener(_parseFacets); 372 378 _facetDebounce?.cancel(); 379 + _richTextScrollController.dispose(); 380 + _textFieldScrollController.dispose(); 373 381 super.dispose(); 374 382 } 375 383 ··· 397 405 final theme = Theme.of(context); 398 406 final text = widget.controller.text; 399 407 final baseStyle = theme.textTheme.bodyMedium?.copyWith(fontSize: 15); 408 + final linkStyle = baseStyle?.copyWith(color: theme.colorScheme.primary); 400 409 401 - final List<InlineSpan> spans = []; 402 - final List<_FacetRange> ranges = _parsedFacets.map((facet) { 403 - final feature = facet['features']?[0] ?? {}; 404 - final type = feature['\$type'] ?? feature['type']; 405 - final data = Map<String, dynamic>.from(feature); 406 - if (type?.contains('link') == true || type == 'app.bsky.richtext.facet#link') { 407 - data['uri'] = feature['uri'] ?? facet['uri']; 408 - } 409 - if (type?.contains('tag') == true || type == 'app.bsky.richtext.facet#tag') { 410 - data['tag'] = feature['tag'] ?? facet['tag']; 411 - } 412 - return _FacetRange( 413 - start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 414 - end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 415 - type: type, 416 - data: data, 417 - ); 418 - }).toList(); 419 - ranges.sort((a, b) => a.start.compareTo(b.start)); 420 - int pos = 0; 421 - final textLength = text.length; 422 - for (final range in ranges) { 423 - final safeStart = range.start.clamp(0, textLength); 424 - final safeEnd = range.end.clamp(0, textLength); 425 - if (safeStart > pos) { 426 - spans.add(TextSpan(text: text.substring(pos, safeStart), style: baseStyle)); 427 - } 428 - if (safeEnd > safeStart) { 429 - final content = text.substring(safeStart, safeEnd); 430 - if ((range.type?.contains('mention') == true || 431 - range.type == 'app.bsky.richtext.facet#mention') && 432 - range.data['did'] != null) { 433 - spans.add( 434 - TextSpan( 435 - text: content, 436 - style: baseStyle?.copyWith(color: theme.colorScheme.primary), 437 - recognizer: TapGestureRecognizer()..onTap = () => _onMentionTap(range.data['did']), 438 - ), 439 - ); 440 - } else if ((range.type?.contains('link') == true || 441 - range.type == 'app.bsky.richtext.facet#link') && 442 - range.data['uri'] != null) { 443 - spans.add( 444 - TextSpan( 445 - text: content, 446 - style: baseStyle?.copyWith(color: theme.colorScheme.primary), 447 - ), 448 - ); 449 - } else if ((range.type?.contains('tag') == true || 450 - range.type == 'app.bsky.richtext.facet#tag') && 451 - range.data['tag'] != null) { 452 - spans.add( 453 - TextSpan( 454 - text: content, 455 - style: baseStyle?.copyWith(color: theme.colorScheme.primary), 456 - ), 457 - ); 458 - } else { 459 - spans.add(TextSpan(text: content, style: baseStyle)); 460 - } 461 - } 462 - pos = safeEnd; 463 - } 464 - if (pos < text.length) { 465 - spans.add(TextSpan(text: text.substring(pos), style: baseStyle)); 466 - } 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 + ); 467 420 return LayoutBuilder( 468 421 builder: (context, constraints) { 469 - return Stack( 470 - children: [ 471 - // RichText for highlight 472 - Padding( 473 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 474 - child: RichText( 475 - text: TextSpan(children: spans), 476 - maxLines: widget.maxLines, 477 - overflow: TextOverflow.visible, 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 + ), 478 442 ), 479 - ), 480 - // Editable TextField for input, but with transparent text so only RichText is visible 481 - TextField( 482 - controller: widget.controller, 483 - maxLines: widget.maxLines, 484 - enabled: widget.enabled, 485 - keyboardType: widget.keyboardType, 486 - onChanged: widget.onChanged, 487 - style: baseStyle?.copyWith(color: const Color(0x01000000)), 488 - cursorColor: theme.colorScheme.primary, 489 - showCursor: true, 490 - enableInteractiveSelection: true, 491 - decoration: InputDecoration( 492 - hintText: widget.hintText, 493 - hintStyle: baseStyle?.copyWith(color: theme.hintColor), 494 - border: InputBorder.none, 495 - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 496 - isDense: true, 497 - prefixIcon: widget.prefixIcon, 498 - suffixIcon: widget.suffixIcon, 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 + ), 499 466 ), 500 - ), 501 - ], 467 + ], 468 + ), 502 469 ); 503 470 }, 504 471 );
+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 ),
+1 -1
pubspec.yaml
··· 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+20 19 + version: 1.0.0+24 20 20 21 21 environment: 22 22 sdk: ^3.8.1