-153
lib/dpop_client.dart
-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
+1
lib/models/photo_exif.dart
+31
-3
lib/models/photo_exif.freezed.dart
+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
+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
+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
+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
+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 {
+66
-10
lib/screens/create_gallery_page.dart
+66
-10
lib/screens/create_gallery_page.dart
···
66
66
});
67
67
}
68
68
69
+
@override
70
+
void dispose() {
71
+
_titleController.dispose();
72
+
_descController.dispose();
73
+
super.dispose();
74
+
}
75
+
69
76
Future<String> _computeMd5(XFile xfile) async {
70
77
final bytes = await xfile.readAsBytes();
71
78
return md5.convert(bytes).toString();
···
73
80
74
81
Future<void> _pickImages() async {
75
82
if (widget.gallery != null) return; // Only allow picking on create
83
+
84
+
final remainingSlots = 10 - _images.length;
85
+
if (remainingSlots <= 0) {
86
+
if (mounted) {
87
+
ScaffoldMessenger.of(context).showSnackBar(
88
+
const SnackBar(
89
+
content: Text('You\'ve already added the maximum of 10 photos.'),
90
+
duration: Duration(seconds: 2),
91
+
),
92
+
);
93
+
}
94
+
return;
95
+
}
96
+
76
97
final picker = ImagePicker();
77
98
final picked = await picker.pickMultiImage(imageQuality: 85);
78
99
if (picked.isNotEmpty) {
···
82
103
final hash = await _computeMd5(img.file);
83
104
existingHashes.add(hash);
84
105
}
106
+
85
107
final newImages = <GalleryImage>[];
86
108
int skipped = 0;
109
+
int limitReached = 0;
110
+
87
111
for (final xfile in picked) {
112
+
// Check if we've reached the limit
113
+
if (_images.length + newImages.length >= 10) {
114
+
limitReached = picked.length - picked.indexOf(xfile);
115
+
break;
116
+
}
117
+
88
118
final hash = await _computeMd5(xfile);
89
119
if (!existingHashes.contains(hash)) {
90
120
newImages.add(GalleryImage(file: xfile, isExisting: false));
···
93
123
skipped++;
94
124
}
95
125
}
126
+
96
127
if (newImages.isNotEmpty) {
97
128
setState(() {
98
129
_images.addAll(newImages);
99
130
});
100
131
}
101
-
if (skipped > 0 && mounted) {
102
-
ScaffoldMessenger.of(context).showSnackBar(
103
-
SnackBar(
104
-
content: Text('Some images were skipped (duplicates).'),
105
-
duration: const Duration(seconds: 2),
106
-
),
107
-
);
132
+
133
+
// Show appropriate feedback messages
134
+
if (mounted) {
135
+
String message = '';
136
+
if (limitReached > 0 && skipped > 0) {
137
+
message =
138
+
'Added ${newImages.length} photos. $skipped duplicates and $limitReached photos skipped (10 photo limit).';
139
+
} else if (limitReached > 0) {
140
+
message =
141
+
'Added ${newImages.length} photos. $limitReached photos skipped (10 photo limit).';
142
+
} else if (skipped > 0) {
143
+
message = 'Added ${newImages.length} photos. $skipped duplicates skipped.';
144
+
}
145
+
146
+
if (message.isNotEmpty) {
147
+
ScaffoldMessenger.of(
148
+
context,
149
+
).showSnackBar(SnackBar(content: Text(message), duration: const Duration(seconds: 3)));
150
+
}
108
151
}
109
152
}
110
153
}
···
116
159
}
117
160
118
161
Future<void> _submit() async {
162
+
FocusScope.of(context).unfocus();
163
+
119
164
final titleGraphemes = _titleController.text.characters.length;
120
165
final descGraphemes = _descController.text.characters.length;
121
166
if (titleGraphemes > 100 || descGraphemes > 1000) {
···
261
306
),
262
307
leading: CupertinoButton(
263
308
padding: EdgeInsets.zero,
264
-
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
309
+
onPressed: _submitting
310
+
? null
311
+
: () {
312
+
FocusScope.of(context).unfocus(); // Force keyboard to close
313
+
Navigator.of(context).pop();
314
+
},
265
315
child: Text(
266
316
'Cancel',
267
317
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600),
···
388
438
children: [
389
439
Expanded(
390
440
child: AppButton(
391
-
label: 'Add photos (${_images.length}/10)',
392
-
onPressed: _pickImages,
441
+
label: _images.length >= 10
442
+
? 'Photos limit reached (10/10)'
443
+
: 'Add photos (${_images.length}/10)',
444
+
onPressed: _images.length >= 10 ? null : _pickImages,
393
445
icon: AppIcons.photoLibrary,
394
446
variant: AppButtonVariant.primary,
395
447
height: 40,
···
493
545
494
546
Future<String?> showCreateGallerySheet(BuildContext context, {Gallery? gallery}) async {
495
547
final theme = Theme.of(context);
548
+
549
+
FocusScope.of(context).unfocus();
550
+
496
551
final result = await showCupertinoSheet(
497
552
context: context,
498
553
useNestedNavigation: false,
···
501
556
child: CreateGalleryPage(gallery: gallery),
502
557
),
503
558
);
559
+
504
560
// Restore status bar style or any other cleanup
505
561
SystemChrome.setSystemUIOverlayStyle(
506
562
theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
+601
lib/screens/photo_library_page.dart
+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
+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
+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
+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
-1
lib/widgets/edit_profile_sheet.dart
+14
-63
lib/widgets/faceted_text.dart
+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
+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
+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
+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