+2
-1
.vscode/settings.json
+2
-1
.vscode/settings.json
+2
-8
lib/api.dart
+2
-8
lib/api.dart
···
26
26
27
27
Future<Session?> refreshSession(Session session) async {
28
28
final url = Uri.parse('$_apiUrl/api/token/refresh');
29
-
final headers = {
30
-
'Authorization': 'Bearer ${session.token}',
31
-
'Content-Type': 'application/json',
32
-
};
29
+
final headers = {'Content-Type': 'application/json'};
33
30
try {
34
31
final response = await http.post(
35
32
url,
···
51
48
52
49
Future<bool> revokeSession(Session session) async {
53
50
final url = Uri.parse('$_apiUrl/api/token/revoke');
54
-
final headers = {
55
-
'Authorization': 'Bearer ${session.token}',
56
-
'Content-Type': 'application/json',
57
-
};
51
+
final headers = {'Content-Type': 'application/json'};
58
52
try {
59
53
final response = await http.post(
60
54
url,
-153
lib/dpop_client.dart
-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
};
+22
lib/providers/actor_search_provider.dart
+22
lib/providers/actor_search_provider.dart
···
1
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+
3
+
import '../api.dart';
4
+
import '../models/profile.dart';
5
+
6
+
final actorSearchProvider = StateNotifierProvider<ActorSearchNotifier, Map<String, List<Profile>>>(
7
+
(ref) => ActorSearchNotifier(),
8
+
);
9
+
10
+
class ActorSearchNotifier extends StateNotifier<Map<String, List<Profile>>> {
11
+
ActorSearchNotifier() : super({});
12
+
13
+
Future<List<Profile>> search(String query) async {
14
+
if (query.isEmpty) return [];
15
+
if (state.containsKey(query)) {
16
+
return state[query]!;
17
+
}
18
+
final results = await apiService.searchActors(query);
19
+
state = {...state, query: results};
20
+
return results;
21
+
}
22
+
}
+15
-10
lib/providers/gallery_cache_provider.dart
+15
-10
lib/providers/gallery_cache_provider.dart
···
1
1
import 'dart:async';
2
2
import 'dart:io';
3
3
4
-
import 'package:bluesky_text/bluesky_text.dart';
5
4
import 'package:flutter/foundation.dart';
6
5
import 'package:grain/models/gallery_photo.dart';
7
6
import 'package:grain/models/procedures/apply_alts_update.dart';
···
43
42
44
43
void setGalleriesForActor(String did, List<Gallery> galleries) {
45
44
setGalleries(galleries);
46
-
// Optionally, you could keep a mapping of actor DID to gallery URIs if needed
47
-
}
48
-
49
-
Future<List<Map<String, dynamic>>> _extractFacets(String text) async {
50
-
final blueskyText = BlueskyText(text);
51
-
final entities = blueskyText.entities;
52
-
final facets = await entities.toFacets();
53
-
return List<Map<String, dynamic>>.from(facets);
54
45
}
55
46
56
47
Future<void> toggleFavorite(String uri) async {
···
109
100
required List<XFile> xfiles,
110
101
int? startPosition,
111
102
bool includeExif = true,
103
+
void Function(int imageIndex, double progress)? onProgress,
112
104
}) async {
113
105
// Fetch the latest gallery from the API to avoid stale state
114
106
final latestGallery = await apiService.getGallery(uri: galleryUri);
···
120
112
final int positionOffset = startPosition ?? initialCount;
121
113
final List<String> photoUris = [];
122
114
int position = positionOffset;
123
-
for (final xfile in xfiles) {
115
+
for (int i = 0; i < xfiles.length; i++) {
116
+
final xfile = xfiles[i];
117
+
// Report progress if callback is provided
118
+
onProgress?.call(i, 0.0);
119
+
124
120
final file = File(xfile.path);
125
121
// Parse EXIF if requested
126
122
final exif = includeExif ? await parseAndNormalizeExif(file: file) : null;
123
+
124
+
// Simulate progress steps
125
+
for (int p = 1; p <= 10; p++) {
126
+
await Future.delayed(const Duration(milliseconds: 30));
127
+
onProgress?.call(i, p / 10.0);
128
+
}
129
+
127
130
// Resize the image
128
131
final resizedResult = await compute<File, ResizeResult>((f) => resizeImage(file: f), file);
129
132
// Upload the blob
···
174
177
required String description,
175
178
required List<XFile> xfiles,
176
179
bool includeExif = true,
180
+
void Function(int imageIndex, double progress)? onProgress,
177
181
}) async {
178
182
final res = await apiService.createGallery(
179
183
request: CreateGalleryRequest(title: title, description: description),
···
183
187
galleryUri: res.galleryUri,
184
188
xfiles: xfiles,
185
189
includeExif: includeExif,
190
+
onProgress: onProgress,
186
191
);
187
192
return (res.galleryUri, photoUris);
188
193
}
+1
-1
lib/providers/gallery_cache_provider.g.dart
+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 {
+393
-198
lib/screens/create_gallery_page.dart
+393
-198
lib/screens/create_gallery_page.dart
···
11
11
import 'package:grain/models/gallery.dart';
12
12
import 'package:grain/providers/profile_provider.dart';
13
13
import 'package:grain/widgets/app_button.dart';
14
+
import 'package:grain/widgets/faceted_text_field.dart';
14
15
import 'package:grain/widgets/plain_text_field.dart';
16
+
import 'package:grain/widgets/upload_progress_overlay.dart';
15
17
import 'package:image_picker/image_picker.dart';
16
18
17
19
import '../providers/gallery_cache_provider.dart';
···
34
36
}
35
37
36
38
class _CreateGalleryPageState extends State<CreateGalleryPage> {
39
+
bool isValidGraphemeLength(String input, int maxLength) {
40
+
return input.characters.length <= maxLength;
41
+
}
42
+
37
43
final _titleController = TextEditingController();
38
44
final _descController = TextEditingController();
39
45
final List<GalleryImage> _images = [];
40
46
bool _submitting = false;
41
47
bool _includeExif = true;
42
48
49
+
// Upload progress state
50
+
bool _showUploadOverlay = false;
51
+
int _currentUploadIndex = 0;
52
+
double _currentUploadProgress = 0.0;
53
+
43
54
@override
44
55
void initState() {
45
56
super.initState();
···
50
61
_titleController.addListener(() {
51
62
setState(() {});
52
63
});
64
+
_descController.addListener(() {
65
+
setState(() {});
66
+
});
67
+
}
68
+
69
+
@override
70
+
void dispose() {
71
+
_titleController.dispose();
72
+
_descController.dispose();
73
+
super.dispose();
53
74
}
54
75
55
76
Future<String> _computeMd5(XFile xfile) async {
···
59
80
60
81
Future<void> _pickImages() async {
61
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
+
62
97
final picker = ImagePicker();
63
98
final picked = await picker.pickMultiImage(imageQuality: 85);
64
99
if (picked.isNotEmpty) {
···
68
103
final hash = await _computeMd5(img.file);
69
104
existingHashes.add(hash);
70
105
}
106
+
71
107
final newImages = <GalleryImage>[];
72
108
int skipped = 0;
109
+
int limitReached = 0;
110
+
73
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
+
74
118
final hash = await _computeMd5(xfile);
75
119
if (!existingHashes.contains(hash)) {
76
120
newImages.add(GalleryImage(file: xfile, isExisting: false));
···
79
123
skipped++;
80
124
}
81
125
}
126
+
82
127
if (newImages.isNotEmpty) {
83
128
setState(() {
84
129
_images.addAll(newImages);
85
130
});
86
131
}
87
-
if (skipped > 0 && mounted) {
88
-
ScaffoldMessenger.of(context).showSnackBar(
89
-
SnackBar(
90
-
content: Text('Some images were skipped (duplicates).'),
91
-
duration: const Duration(seconds: 2),
92
-
),
93
-
);
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
+
}
94
151
}
95
152
}
96
153
}
···
102
159
}
103
160
104
161
Future<void> _submit() async {
162
+
FocusScope.of(context).unfocus();
163
+
164
+
final titleGraphemes = _titleController.text.characters.length;
165
+
final descGraphemes = _descController.text.characters.length;
166
+
if (titleGraphemes > 100 || descGraphemes > 1000) {
167
+
if (mounted) {
168
+
await showDialog(
169
+
context: context,
170
+
builder: (context) => AlertDialog(
171
+
title: const Text('Character Limit Exceeded'),
172
+
content: Text(
173
+
titleGraphemes > 100
174
+
? 'Title must be 100 characters or fewer.'
175
+
: 'Description must be 1000 characters or fewer.',
176
+
),
177
+
actions: [
178
+
TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()),
179
+
],
180
+
),
181
+
);
182
+
}
183
+
return;
184
+
}
105
185
if (widget.gallery == null && _images.length > 10) {
106
186
if (mounted) {
107
187
await showDialog(
···
109
189
builder: (context) => AlertDialog(
110
190
title: const Text('Photo Limit'),
111
191
content: const Text(
112
-
'You can only add up to 10 photos on initial create but can add more later on.',
192
+
'You can only add up to 10 photos initially but you can add more later on.',
113
193
),
114
194
actions: [
115
195
TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()),
···
122
202
setState(() => _submitting = true);
123
203
String? galleryUri;
124
204
final container = ProviderScope.containerOf(context, listen: false);
125
-
if (widget.gallery == null) {
126
-
// Use provider to create gallery and add photos
127
-
final newImages = _images.where((img) => !img.isExisting).toList();
128
-
final galleryCache = container.read(galleryCacheProvider.notifier);
129
-
final (createdUri, newPhotoUris) = await galleryCache.createGalleryAndAddPhotos(
130
-
title: _titleController.text.trim(),
131
-
description: _descController.text.trim(),
132
-
xfiles: newImages.map((img) => img.file).toList(),
133
-
includeExif: _includeExif,
134
-
);
135
-
galleryUri = createdUri;
136
-
// Update profile provider state to include new gallery
137
-
if (galleryUri != null && mounted) {
138
-
final newGallery = container.read(galleryCacheProvider)[galleryUri];
139
-
final profileNotifier = container.read(
140
-
profileNotifierProvider(apiService.currentUser!.did).notifier,
205
+
try {
206
+
if (widget.gallery == null) {
207
+
final newImages = _images.where((img) => !img.isExisting).toList();
208
+
final galleryCache = container.read(galleryCacheProvider.notifier);
209
+
if (newImages.isNotEmpty) {
210
+
setState(() {
211
+
_showUploadOverlay = true;
212
+
_currentUploadIndex = 0;
213
+
_currentUploadProgress = 0.0;
214
+
});
215
+
}
216
+
List<XFile> xfiles = newImages.map((img) => img.file).toList();
217
+
String? createdUri;
218
+
final (uri, photoUris) = await galleryCache.createGalleryAndAddPhotos(
219
+
title: _titleController.text.trim(),
220
+
description: _descController.text.trim(),
221
+
xfiles: xfiles,
222
+
includeExif: _includeExif,
223
+
onProgress: (int idx, double prog) {
224
+
setState(() {
225
+
_currentUploadIndex = idx;
226
+
_currentUploadProgress = prog;
227
+
});
228
+
},
141
229
);
142
-
if (newGallery != null) {
143
-
profileNotifier.addGalleryToProfile(newGallery);
230
+
createdUri = uri;
231
+
setState(() {
232
+
_showUploadOverlay = false;
233
+
});
234
+
galleryUri = createdUri;
235
+
// Update profile provider state to include new gallery
236
+
if (galleryUri != null && mounted) {
237
+
final newGallery = container.read(galleryCacheProvider)[galleryUri];
238
+
final profileNotifier = container.read(
239
+
profileNotifierProvider(apiService.currentUser!.did).notifier,
240
+
);
241
+
if (newGallery != null) {
242
+
profileNotifier.addGalleryToProfile(newGallery);
243
+
}
144
244
}
245
+
} else {
246
+
galleryUri = widget.gallery!.uri;
247
+
final galleryCache = container.read(galleryCacheProvider.notifier);
248
+
await galleryCache.updateGalleryDetails(
249
+
galleryUri: galleryUri,
250
+
title: _titleController.text.trim(),
251
+
description: _descController.text.trim(),
252
+
createdAt: widget.gallery!.createdAt ?? DateTime.now().toUtc().toIso8601String(),
253
+
);
145
254
}
146
-
} else {
147
-
galleryUri = widget.gallery!.uri;
148
-
final galleryCache = container.read(galleryCacheProvider.notifier);
149
-
await galleryCache.updateGalleryDetails(
150
-
galleryUri: galleryUri,
151
-
title: _titleController.text.trim(),
152
-
description: _descController.text.trim(),
153
-
createdAt: widget.gallery!.createdAt ?? DateTime.now().toUtc().toIso8601String(),
154
-
);
155
-
}
156
-
setState(() => _submitting = false);
157
-
if (mounted && galleryUri != null) {
158
-
FocusScope.of(context).unfocus(); // Force keyboard to close
159
-
Navigator.of(context).pop(galleryUri); // Pop with galleryUri if created
160
-
if (widget.gallery == null) {
161
-
Navigator.of(context).push(
162
-
MaterialPageRoute(
163
-
builder: (context) =>
164
-
GalleryPage(uri: galleryUri!, currentUserDid: apiService.currentUser?.did),
255
+
setState(() => _submitting = false);
256
+
if (mounted && galleryUri != null) {
257
+
FocusScope.of(context).unfocus(); // Force keyboard to close
258
+
Navigator.of(context).pop(galleryUri); // Pop with galleryUri if created
259
+
if (widget.gallery == null) {
260
+
Navigator.of(context).push(
261
+
MaterialPageRoute(
262
+
builder: (context) =>
263
+
GalleryPage(uri: galleryUri!, currentUserDid: apiService.currentUser?.did),
264
+
),
265
+
);
266
+
}
267
+
} else if (mounted) {
268
+
FocusScope.of(context).unfocus(); // Force keyboard to close
269
+
Navigator.of(context).pop();
270
+
}
271
+
} catch (e) {
272
+
setState(() {
273
+
_submitting = false;
274
+
_showUploadOverlay = false;
275
+
});
276
+
if (mounted) {
277
+
await showDialog(
278
+
context: context,
279
+
builder: (context) => AlertDialog(
280
+
title: const Text('Upload Failed'),
281
+
content: Text('An error occurred while uploading:\n\n${e.toString()}'),
282
+
actions: [
283
+
TextButton(child: const Text('OK'), onPressed: () => Navigator.of(context).pop()),
284
+
],
165
285
),
166
286
);
167
287
}
168
-
} else if (mounted) {
169
-
FocusScope.of(context).unfocus(); // Force keyboard to close
170
-
Navigator.of(context).pop();
171
288
}
172
289
}
173
290
···
175
292
Widget build(BuildContext context) {
176
293
final theme = Theme.of(context);
177
294
178
-
return CupertinoPageScaffold(
179
-
backgroundColor: theme.colorScheme.surface,
180
-
navigationBar: CupertinoNavigationBar(
181
-
backgroundColor: theme.colorScheme.surface,
182
-
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)),
183
-
middle: Text(
184
-
widget.gallery == null ? 'New Gallery' : 'Edit Gallery',
185
-
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
186
-
),
187
-
leading: CupertinoButton(
188
-
padding: EdgeInsets.zero,
189
-
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
190
-
child: Text(
191
-
'Cancel',
192
-
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600),
193
-
),
194
-
),
195
-
trailing: CupertinoButton(
196
-
padding: EdgeInsets.zero,
197
-
onPressed: _submitting || _titleController.text.trim().isEmpty ? null : _submit,
198
-
child: Row(
199
-
mainAxisSize: MainAxisSize.min,
200
-
children: [
201
-
Text(
202
-
widget.gallery == null ? 'Create' : 'Save',
203
-
style: TextStyle(
204
-
color: (_submitting || _titleController.text.trim().isEmpty)
205
-
? theme.disabledColor
206
-
: theme.colorScheme.primary,
207
-
fontWeight: FontWeight.w600,
208
-
),
295
+
return Stack(
296
+
children: [
297
+
Positioned.fill(
298
+
child: CupertinoPageScaffold(
299
+
backgroundColor: theme.colorScheme.surface,
300
+
navigationBar: CupertinoNavigationBar(
301
+
backgroundColor: theme.colorScheme.surface,
302
+
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)),
303
+
middle: Text(
304
+
widget.gallery == null ? 'New Gallery' : 'Edit Gallery',
305
+
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
209
306
),
210
-
if (_submitting) ...[
211
-
const SizedBox(width: 8),
212
-
SizedBox(
213
-
width: 16,
214
-
height: 16,
215
-
child: CircularProgressIndicator(
216
-
strokeWidth: 2,
217
-
valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
218
-
semanticsLabel: widget.gallery == null ? 'Creating' : 'Saving',
219
-
),
307
+
leading: CupertinoButton(
308
+
padding: EdgeInsets.zero,
309
+
onPressed: _submitting
310
+
? null
311
+
: () {
312
+
FocusScope.of(context).unfocus(); // Force keyboard to close
313
+
Navigator.of(context).pop();
314
+
},
315
+
child: Text(
316
+
'Cancel',
317
+
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600),
220
318
),
221
-
],
222
-
],
223
-
),
224
-
),
225
-
),
226
-
child: SafeArea(
227
-
bottom: true,
228
-
child: SingleChildScrollView(
229
-
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
230
-
child: Column(
231
-
crossAxisAlignment: CrossAxisAlignment.start,
232
-
mainAxisSize: MainAxisSize.min,
233
-
children: [
234
-
PlainTextField(
235
-
label: 'Title',
236
-
controller: _titleController,
237
-
hintText: 'Enter a title',
238
319
),
239
-
const SizedBox(height: 16),
240
-
PlainTextField(
241
-
label: 'Description',
242
-
controller: _descController,
243
-
maxLines: 6,
244
-
hintText: 'Enter a description',
245
-
),
246
-
const SizedBox(height: 16),
247
-
if (widget.gallery == null)
248
-
Row(
320
+
trailing: CupertinoButton(
321
+
padding: EdgeInsets.zero,
322
+
onPressed: _submitting || _titleController.text.trim().isEmpty ? null : _submit,
323
+
child: Row(
324
+
mainAxisSize: MainAxisSize.min,
249
325
children: [
250
-
Expanded(
251
-
child: Text(
252
-
'Include image metadata (EXIF)',
253
-
style: theme.textTheme.bodyMedium,
326
+
Text(
327
+
widget.gallery == null ? 'Create' : 'Save',
328
+
style: TextStyle(
329
+
color: (_submitting || _titleController.text.trim().isEmpty)
330
+
? theme.disabledColor
331
+
: theme.colorScheme.primary,
332
+
fontWeight: FontWeight.w600,
254
333
),
255
334
),
256
-
Switch(
257
-
value: _includeExif,
258
-
onChanged: (val) {
259
-
setState(() {
260
-
_includeExif = val;
261
-
});
262
-
},
263
-
),
264
-
],
265
-
),
266
-
const SizedBox(height: 16),
267
-
if (widget.gallery == null)
268
-
Row(
269
-
children: [
270
-
Expanded(
271
-
child: AppButton(
272
-
label: 'Upload photos',
273
-
onPressed: _pickImages,
274
-
icon: AppIcons.photoLibrary,
275
-
variant: AppButtonVariant.primary,
276
-
height: 40,
277
-
fontSize: 15,
278
-
borderRadius: 6,
335
+
if (_submitting) ...[
336
+
const SizedBox(width: 8),
337
+
SizedBox(
338
+
width: 16,
339
+
height: 16,
340
+
child: CircularProgressIndicator(
341
+
strokeWidth: 2,
342
+
valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
343
+
semanticsLabel: widget.gallery == null ? 'Creating' : 'Saving',
344
+
),
279
345
),
280
-
),
346
+
],
281
347
],
282
348
),
283
-
if (_images.isNotEmpty) ...[
284
-
const SizedBox(height: 16),
285
-
GridView.builder(
286
-
shrinkWrap: true,
287
-
physics: const NeverScrollableScrollPhysics(),
288
-
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
289
-
crossAxisCount: 3,
290
-
crossAxisSpacing: 8,
291
-
mainAxisSpacing: 8,
292
-
),
293
-
itemCount: _images.length,
294
-
itemBuilder: (context, index) {
295
-
final galleryImage = _images[index];
296
-
return Stack(
297
-
children: [
298
-
Positioned.fill(
299
-
child: Container(
300
-
decoration: BoxDecoration(
301
-
color: theme.colorScheme.surfaceContainerHighest,
302
-
borderRadius: BorderRadius.circular(8),
349
+
),
350
+
),
351
+
child: SafeArea(
352
+
bottom: true,
353
+
child: GestureDetector(
354
+
behavior: HitTestBehavior.translucent,
355
+
onTap: () => FocusScope.of(context).unfocus(),
356
+
child: SingleChildScrollView(
357
+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
358
+
child: Column(
359
+
crossAxisAlignment: CrossAxisAlignment.start,
360
+
mainAxisSize: MainAxisSize.min,
361
+
children: [
362
+
PlainTextField(
363
+
label: 'Title',
364
+
controller: _titleController,
365
+
hintText: 'Enter a title',
366
+
),
367
+
Padding(
368
+
padding: const EdgeInsets.only(top: 4),
369
+
child: Row(
370
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
371
+
children: [
372
+
const SizedBox(),
373
+
Text(
374
+
'${_titleController.text.characters.length}/100',
375
+
style: theme.textTheme.bodySmall?.copyWith(
376
+
color: _titleController.text.characters.length > 100
377
+
? theme.colorScheme.error
378
+
: theme.textTheme.bodySmall?.color,
379
+
),
303
380
),
304
-
child: ClipRRect(
305
-
borderRadius: BorderRadius.circular(8),
306
-
child: Image.file(File(galleryImage.file.path), fit: BoxFit.cover),
381
+
],
382
+
),
383
+
),
384
+
const SizedBox(height: 16),
385
+
FacetedTextField(
386
+
label: 'Description',
387
+
controller: _descController,
388
+
maxLines: 6,
389
+
hintText: 'Enter a description',
390
+
),
391
+
Padding(
392
+
padding: const EdgeInsets.only(top: 4),
393
+
child: Row(
394
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
395
+
children: [
396
+
const SizedBox(),
397
+
Text(
398
+
'${_descController.text.characters.length}/1000',
399
+
style: theme.textTheme.bodySmall?.copyWith(
400
+
color: _descController.text.characters.length > 1000
401
+
? theme.colorScheme.error
402
+
: theme.textTheme.bodySmall?.color,
403
+
),
307
404
),
308
-
),
405
+
],
309
406
),
310
-
if (galleryImage.isExisting)
311
-
Positioned(
312
-
left: 2,
313
-
top: 2,
314
-
child: Container(
315
-
padding: const EdgeInsets.all(2),
316
-
decoration: BoxDecoration(
317
-
color: theme.colorScheme.secondary.withOpacity(0.7),
318
-
borderRadius: BorderRadius.circular(4),
407
+
),
408
+
const SizedBox(height: 16),
409
+
if (widget.gallery == null)
410
+
Row(
411
+
children: [
412
+
Expanded(
413
+
child: Text(
414
+
'Include image metadata (EXIF)',
415
+
style: theme.textTheme.bodyMedium,
319
416
),
320
-
child: Icon(
321
-
AppIcons.checkCircle,
322
-
color: theme.colorScheme.onSecondary,
323
-
size: 16,
324
-
),
417
+
),
418
+
Switch(
419
+
value: _includeExif,
420
+
onChanged: (val) {
421
+
setState(() {
422
+
_includeExif = val;
423
+
});
424
+
},
325
425
),
426
+
],
427
+
),
428
+
const SizedBox(height: 16),
429
+
if (widget.gallery == null) ...[
430
+
Padding(
431
+
padding: const EdgeInsets.only(bottom: 8.0),
432
+
child: Text(
433
+
"You can add up to 10 photos when initially creating a gallery, but you can add more later on. Galleries can capture a moment in time or evolve as an ongoing collection.",
434
+
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
326
435
),
327
-
Positioned(
328
-
top: 2,
329
-
right: 2,
330
-
child: GestureDetector(
331
-
onTap: () => _removeImage(index),
332
-
child: Container(
333
-
decoration: BoxDecoration(
334
-
color: Colors.grey.withOpacity(0.7),
335
-
shape: BoxShape.circle,
436
+
),
437
+
Row(
438
+
children: [
439
+
Expanded(
440
+
child: AppButton(
441
+
label: _images.length >= 10
442
+
? 'Photos limit reached (10/10)'
443
+
: 'Add photos (${_images.length}/10)',
444
+
onPressed: _images.length >= 10 ? null : _pickImages,
445
+
icon: AppIcons.photoLibrary,
446
+
variant: AppButtonVariant.primary,
447
+
height: 40,
448
+
fontSize: 15,
449
+
borderRadius: 6,
336
450
),
337
-
padding: const EdgeInsets.all(4),
338
-
child: const Icon(AppIcons.close, color: Colors.white, size: 20),
339
451
),
452
+
],
453
+
),
454
+
],
455
+
if (_images.isNotEmpty) ...[
456
+
const SizedBox(height: 16),
457
+
GridView.builder(
458
+
shrinkWrap: true,
459
+
physics: const NeverScrollableScrollPhysics(),
460
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
461
+
crossAxisCount: 3,
462
+
crossAxisSpacing: 8,
463
+
mainAxisSpacing: 8,
340
464
),
465
+
itemCount: _images.length,
466
+
itemBuilder: (context, index) {
467
+
final galleryImage = _images[index];
468
+
return Stack(
469
+
children: [
470
+
Positioned.fill(
471
+
child: Container(
472
+
decoration: BoxDecoration(
473
+
color: theme.colorScheme.surfaceContainerHighest,
474
+
borderRadius: BorderRadius.circular(8),
475
+
),
476
+
child: ClipRRect(
477
+
borderRadius: BorderRadius.circular(8),
478
+
child: Image.file(
479
+
File(galleryImage.file.path),
480
+
fit: BoxFit.cover,
481
+
),
482
+
),
483
+
),
484
+
),
485
+
if (galleryImage.isExisting)
486
+
Positioned(
487
+
left: 2,
488
+
top: 2,
489
+
child: Container(
490
+
padding: const EdgeInsets.all(2),
491
+
decoration: BoxDecoration(
492
+
color: theme.colorScheme.secondary.withOpacity(0.7),
493
+
borderRadius: BorderRadius.circular(4),
494
+
),
495
+
child: Icon(
496
+
AppIcons.checkCircle,
497
+
color: theme.colorScheme.onSecondary,
498
+
size: 16,
499
+
),
500
+
),
501
+
),
502
+
Positioned(
503
+
top: 2,
504
+
right: 2,
505
+
child: GestureDetector(
506
+
onTap: () => _removeImage(index),
507
+
child: Container(
508
+
decoration: BoxDecoration(
509
+
color: Colors.grey.withOpacity(0.7),
510
+
shape: BoxShape.circle,
511
+
),
512
+
padding: const EdgeInsets.all(4),
513
+
child: const Icon(
514
+
AppIcons.close,
515
+
color: Colors.white,
516
+
size: 20,
517
+
),
518
+
),
519
+
),
520
+
),
521
+
],
522
+
);
523
+
},
341
524
),
342
525
],
343
-
);
344
-
},
526
+
],
527
+
),
345
528
),
346
-
],
347
-
],
529
+
),
530
+
),
348
531
),
349
532
),
350
-
),
533
+
if (_showUploadOverlay && widget.gallery == null) ...[
534
+
UploadProgressOverlay(
535
+
images: _images.where((img) => !img.isExisting).toList(),
536
+
currentIndex: _currentUploadIndex,
537
+
progress: _currentUploadProgress,
538
+
visible: _showUploadOverlay,
539
+
),
540
+
],
541
+
],
351
542
);
352
543
}
353
544
}
354
545
355
546
Future<String?> showCreateGallerySheet(BuildContext context, {Gallery? gallery}) async {
356
547
final theme = Theme.of(context);
548
+
549
+
FocusScope.of(context).unfocus();
550
+
357
551
final result = await showCupertinoSheet(
358
552
context: context,
359
553
useNestedNavigation: false,
···
362
556
child: CreateGalleryPage(gallery: gallery),
363
557
),
364
558
);
559
+
365
560
// Restore status bar style or any other cleanup
366
561
SystemChrome.setSystemUIOverlayStyle(
367
562
theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
+11
-4
lib/screens/gallery_edit_photos_sheet.dart
+11
-4
lib/screens/gallery_edit_photos_sheet.dart
···
222
222
context,
223
223
actorDid: actorDid,
224
224
galleryUri: widget.galleryUri,
225
-
onSelect: (photos) {
226
-
setState(() {
227
-
_photos.addAll(photos);
228
-
});
225
+
onSelect: (photos) async {
226
+
// Wait for provider to update after adding
227
+
await Future.delayed(const Duration(milliseconds: 100));
228
+
final updatedGallery = ref.read(
229
+
galleryCacheProvider,
230
+
)[widget.galleryUri];
231
+
if (updatedGallery != null && mounted) {
232
+
setState(() {
233
+
_photos = List.from(updatedGallery.items);
234
+
});
235
+
}
229
236
},
230
237
);
231
238
},
+8
-10
lib/screens/gallery_page.dart
+8
-10
lib/screens/gallery_page.dart
···
273
273
);
274
274
}
275
275
: null,
276
-
child: Row(
277
-
crossAxisAlignment: CrossAxisAlignment.center,
276
+
child: Column(
277
+
crossAxisAlignment: CrossAxisAlignment.start,
278
278
children: [
279
279
Text(
280
280
gallery.creator?.displayName ?? '',
···
282
282
fontWeight: FontWeight.w600,
283
283
),
284
284
),
285
-
if ((gallery.creator?.displayName ?? '').isNotEmpty &&
286
-
(gallery.creator?.handle ?? '').isNotEmpty)
287
-
const SizedBox(width: 8),
288
-
Text(
289
-
'@${gallery.creator?.handle ?? ''}',
290
-
style: theme.textTheme.bodyMedium?.copyWith(
291
-
color: theme.hintColor,
285
+
if ((gallery.creator?.handle ?? '').isNotEmpty)
286
+
Text(
287
+
'@${gallery.creator?.handle ?? ''}',
288
+
style: theme.textTheme.bodyMedium?.copyWith(
289
+
color: theme.hintColor,
290
+
),
292
291
),
293
-
),
294
292
],
295
293
),
296
294
),
+6
-1
lib/screens/library_photos_select_sheet.dart
+6
-1
lib/screens/library_photos_select_sheet.dart
···
40
40
Future<void> _fetchPhotos() async {
41
41
setState(() => _loading = true);
42
42
final photos = await apiService.fetchActorPhotos(did: widget.actorDid);
43
+
// Get gallery items from provider
44
+
final galleryItems = ref.read(galleryCacheProvider)[widget.galleryUri]?.items ?? [];
45
+
final galleryUris = galleryItems.map((item) => item.uri).toSet();
46
+
// Filter out photos already in gallery
47
+
final filteredPhotos = photos.where((photo) => !galleryUris.contains(photo.uri)).toList();
43
48
if (mounted) {
44
49
setState(() {
45
-
_photos = photos;
50
+
_photos = filteredPhotos;
46
51
_loading = false;
47
52
});
48
53
}
+601
lib/screens/photo_library_page.dart
+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
+
}
+12
-14
lib/widgets/add_comment_sheet.dart
+12
-14
lib/widgets/add_comment_sheet.dart
···
5
5
import 'package:grain/app_icons.dart';
6
6
import 'package:grain/widgets/app_image.dart';
7
7
import 'package:grain/widgets/gallery_preview.dart';
8
+
import 'package:grain/widgets/faceted_text_field.dart';
8
9
9
10
Future<void> showAddCommentSheet(
10
11
BuildContext context, {
···
185
186
child: Column(
186
187
crossAxisAlignment: CrossAxisAlignment.start,
187
188
children: [
188
-
Row(
189
+
Column(
190
+
crossAxisAlignment: CrossAxisAlignment.start,
189
191
children: [
190
192
Text(
191
193
creator is Map
···
199
201
? (creator['handle'] ?? '')
200
202
: (creator.handle ?? ''))
201
203
.isNotEmpty) ...[
202
-
const SizedBox(width: 8),
204
+
const SizedBox(height: 1),
203
205
Text(
204
206
'@${creator is Map ? creator['handle'] : creator.handle}',
205
207
style: theme.textTheme.bodySmall?.copyWith(
···
315
317
),
316
318
// Text input
317
319
Expanded(
318
-
child: TextField(
319
-
controller: widget.controller,
320
-
focusNode: _focusNode,
321
-
maxLines: 6,
322
-
minLines: 2,
323
-
style: theme.textTheme.bodyMedium,
324
-
decoration: InputDecoration(
320
+
child: Padding(
321
+
padding: const EdgeInsets.only(left: 10),
322
+
child: FacetedTextField(
323
+
controller: widget.controller,
324
+
maxLines: 6,
325
+
enabled: true,
326
+
keyboardType: TextInputType.multiline,
325
327
hintText: 'Add a comment',
326
-
hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
327
-
border: InputBorder.none,
328
-
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
329
-
isDense: true,
330
-
filled: false,
328
+
// The FacetedTextField handles its own style and padding internally
331
329
),
332
330
),
333
331
),
+15
lib/widgets/app_drawer.dart
+15
lib/widgets/app_drawer.dart
···
2
2
import 'package:grain/api.dart';
3
3
import 'package:grain/app_icons.dart';
4
4
import 'package:grain/screens/log_page.dart';
5
+
import 'package:grain/screens/photo_library_page.dart';
5
6
import 'package:grain/widgets/app_version_text.dart';
6
7
7
8
class AppDrawer extends StatelessWidget {
···
176
177
onTap: () {
177
178
Navigator.pop(context);
178
179
onProfile();
180
+
},
181
+
),
182
+
ListTile(
183
+
leading: Icon(
184
+
AppIcons.photoLibrary,
185
+
size: 18,
186
+
color: activeIndex == 4 ? theme.colorScheme.primary : theme.iconTheme.color,
187
+
),
188
+
title: const Text('Photo Library'),
189
+
onTap: () {
190
+
Navigator.pop(context);
191
+
Navigator.of(
192
+
context,
193
+
).push(MaterialPageRoute(builder: (context) => const PhotoLibraryPage()));
179
194
},
180
195
),
181
196
ListTile(
+71
-25
lib/widgets/edit_profile_sheet.dart
+71
-25
lib/widgets/edit_profile_sheet.dart
···
4
4
import 'package:flutter/material.dart';
5
5
import 'package:flutter/services.dart';
6
6
import 'package:grain/app_icons.dart';
7
+
import 'package:grain/widgets/faceted_text_field.dart';
7
8
import 'package:grain/widgets/plain_text_field.dart';
8
9
import 'package:image_picker/image_picker.dart';
9
10
···
61
62
late TextEditingController _descriptionController;
62
63
XFile? _selectedAvatar;
63
64
bool _saving = false;
64
-
bool _hasChanged = false;
65
+
static const int maxDisplayNameGraphemes = 64;
66
+
static const int maxDescriptionGraphemes = 256;
65
67
66
68
@override
67
69
void initState() {
···
69
71
_displayNameController = TextEditingController(text: widget.initialDisplayName ?? '');
70
72
_descriptionController = TextEditingController(text: widget.initialDescription ?? '');
71
73
_displayNameController.addListener(_onInputChanged);
72
-
_descriptionController.addListener(_onInputChanged);
74
+
_descriptionController.addListener(_onDescriptionChanged);
75
+
}
76
+
77
+
void _onDescriptionChanged() {
78
+
setState(() {}); // For character count
73
79
}
74
80
75
81
void _onInputChanged() {
76
-
final displayName = _displayNameController.text.trim();
77
-
final initialDisplayName = widget.initialDisplayName ?? '';
78
-
final displayNameChanged = displayName != initialDisplayName;
79
-
final descriptionChanged =
80
-
_descriptionController.text.trim() != (widget.initialDescription ?? '');
81
-
final avatarChanged = _selectedAvatar != null;
82
-
// Only allow Save if displayName is not empty and at least one field changed
83
-
final changed =
84
-
(displayNameChanged || descriptionChanged || avatarChanged) && displayName.isNotEmpty;
85
-
if (_hasChanged != changed) {
86
-
setState(() {
87
-
_hasChanged = changed;
88
-
});
89
-
}
82
+
setState(() {
83
+
// Trigger rebuild to update character counts
84
+
});
90
85
}
91
86
92
87
@override
93
88
void dispose() {
94
-
_displayNameController.removeListener(_onInputChanged);
95
-
_descriptionController.removeListener(_onInputChanged);
96
89
_displayNameController.dispose();
97
90
_descriptionController.dispose();
98
91
super.dispose();
···
104
97
if (picked != null) {
105
98
setState(() {
106
99
_selectedAvatar = picked;
107
-
_onInputChanged();
108
100
});
109
101
}
110
102
}
···
113
105
Widget build(BuildContext context) {
114
106
final theme = Theme.of(context);
115
107
final avatarRadius = 44.0;
108
+
final displayNameGraphemes = _displayNameController.text.characters.length;
109
+
final descriptionGraphemes = _descriptionController.text.characters.length;
116
110
return CupertinoPageScaffold(
117
111
backgroundColor: theme.colorScheme.surface,
118
112
navigationBar: CupertinoNavigationBar(
···
132
126
),
133
127
trailing: CupertinoButton(
134
128
padding: EdgeInsets.zero,
135
-
onPressed: (!_hasChanged || _saving)
129
+
onPressed: _saving
136
130
? null
137
131
: () async {
132
+
if (displayNameGraphemes > maxDisplayNameGraphemes ||
133
+
descriptionGraphemes > maxDescriptionGraphemes) {
134
+
await showDialog(
135
+
context: context,
136
+
builder: (context) => AlertDialog(
137
+
title: const Text('Character Limit Exceeded'),
138
+
content: Text(
139
+
displayNameGraphemes > maxDisplayNameGraphemes
140
+
? 'Display Name must be $maxDisplayNameGraphemes characters or fewer.'
141
+
: 'Description must be $maxDescriptionGraphemes characters or fewer.',
142
+
),
143
+
actions: [
144
+
TextButton(
145
+
child: const Text('OK'),
146
+
onPressed: () => Navigator.of(context).pop(),
147
+
),
148
+
],
149
+
),
150
+
);
151
+
return;
152
+
}
138
153
if (widget.onSave != null) {
139
154
setState(() {
140
155
_saving = true;
···
155
170
Text(
156
171
'Save',
157
172
style: TextStyle(
158
-
color: (!_hasChanged || _saving)
159
-
? theme.disabledColor
160
-
: theme.colorScheme.primary,
173
+
color: _saving ? theme.disabledColor : theme.colorScheme.primary,
161
174
fontWeight: FontWeight.w600,
162
175
),
163
176
),
···
233
246
controller: _displayNameController,
234
247
maxLines: 1,
235
248
),
249
+
Padding(
250
+
padding: const EdgeInsets.only(top: 4),
251
+
child: Row(
252
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
253
+
children: [
254
+
const SizedBox(),
255
+
Text(
256
+
'$displayNameGraphemes/$maxDisplayNameGraphemes',
257
+
style: theme.textTheme.bodySmall?.copyWith(
258
+
color: displayNameGraphemes > maxDisplayNameGraphemes
259
+
? theme.colorScheme.error
260
+
: theme.textTheme.bodySmall?.color,
261
+
),
262
+
),
263
+
],
264
+
),
265
+
),
236
266
const SizedBox(height: 12),
237
-
PlainTextField(
267
+
FacetedTextField(
238
268
label: 'Description',
239
269
controller: _descriptionController,
240
270
maxLines: 6,
241
271
),
272
+
Padding(
273
+
padding: const EdgeInsets.only(top: 4),
274
+
child: Row(
275
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
276
+
children: [
277
+
const SizedBox(),
278
+
Text(
279
+
'$descriptionGraphemes/$maxDescriptionGraphemes',
280
+
style: theme.textTheme.bodySmall?.copyWith(
281
+
color: descriptionGraphemes > maxDescriptionGraphemes
282
+
? theme.colorScheme.error
283
+
: theme.textTheme.bodySmall?.color,
284
+
),
285
+
),
286
+
],
287
+
),
288
+
),
242
289
],
243
290
),
244
291
),
245
292
),
246
-
const SizedBox(height: 24),
247
293
],
248
294
),
249
295
),
+14
-63
lib/widgets/faceted_text.dart
+14
-63
lib/widgets/faceted_text.dart
···
1
-
import 'package:flutter/gestures.dart';
2
1
import 'package:flutter/material.dart';
2
+
3
+
import '../utils/facet_utils.dart';
3
4
4
5
class FacetedText extends StatelessWidget {
5
6
final String text;
···
32
33
fontWeight: FontWeight.w600,
33
34
decoration: TextDecoration.underline,
34
35
);
36
+
35
37
if (facets == null || facets!.isEmpty) {
36
38
return Text(text, style: defaultStyle);
37
39
}
38
-
// Build a list of all ranges (start, end, type, data)
39
-
final List<_FacetRange> ranges = facets!.map((facet) {
40
-
final feature = facet['features']?[0] ?? {};
41
-
final type = feature['\$type'] ?? feature['type'];
42
-
return _FacetRange(
43
-
start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0,
44
-
end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0,
45
-
type: type,
46
-
data: feature,
47
-
);
48
-
}).toList();
49
-
ranges.sort((a, b) => a.start.compareTo(b.start));
50
-
int pos = 0;
51
-
final spans = <TextSpan>[];
52
-
for (final range in ranges) {
53
-
if (range.start > pos) {
54
-
spans.add(TextSpan(text: text.substring(pos, range.start), style: defaultStyle));
55
-
}
56
-
final content = text.substring(range.start, range.end);
57
-
if (range.type?.contains('mention') == true && range.data['did'] != null) {
58
-
spans.add(
59
-
TextSpan(
60
-
text: content,
61
-
style: defaultLinkStyle,
62
-
recognizer: TapGestureRecognizer()
63
-
..onTap = onMentionTap != null ? () => onMentionTap!(range.data['did']) : null,
64
-
),
65
-
);
66
-
} else if (range.type?.contains('link') == true && range.data['uri'] != null) {
67
-
spans.add(
68
-
TextSpan(
69
-
text: content,
70
-
style: defaultLinkStyle,
71
-
recognizer: TapGestureRecognizer()
72
-
..onTap = onLinkTap != null ? () => onLinkTap!(range.data['uri']) : null,
73
-
),
74
-
);
75
-
} else if (range.type?.contains('tag') == true && range.data['tag'] != null) {
76
-
spans.add(
77
-
TextSpan(
78
-
text: '#${range.data['tag']}',
79
-
style: defaultLinkStyle,
80
-
recognizer: TapGestureRecognizer()
81
-
..onTap = onTagTap != null ? () => onTagTap!(range.data['tag']) : null,
82
-
),
83
-
);
84
-
} else {
85
-
spans.add(TextSpan(text: content, style: defaultStyle));
86
-
}
87
-
pos = range.end;
88
-
}
89
-
if (pos < text.length) {
90
-
spans.add(TextSpan(text: text.substring(pos), style: defaultStyle));
91
-
}
40
+
41
+
final spans = FacetUtils.processFacets(
42
+
text: text,
43
+
facets: facets,
44
+
defaultStyle: defaultStyle,
45
+
linkStyle: defaultLinkStyle,
46
+
onMentionTap: onMentionTap,
47
+
onLinkTap: onLinkTap,
48
+
onTagTap: onTagTap,
49
+
);
50
+
92
51
return RichText(text: TextSpan(children: spans));
93
52
}
94
53
}
95
-
96
-
class _FacetRange {
97
-
final int start;
98
-
final int end;
99
-
final String? type;
100
-
final Map<String, dynamic> data;
101
-
_FacetRange({required this.start, required this.end, required this.type, required this.data});
102
-
}
+473
lib/widgets/faceted_text_field.dart
+473
lib/widgets/faceted_text_field.dart
···
1
+
import 'dart:async';
2
+
3
+
import 'package:bluesky_text/bluesky_text.dart';
4
+
import 'package:flutter/material.dart';
5
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+
7
+
import '../models/profile.dart';
8
+
import '../providers/actor_search_provider.dart';
9
+
import '../utils/facet_utils.dart';
10
+
11
+
class FacetedTextField extends ConsumerStatefulWidget {
12
+
final String? label;
13
+
final TextEditingController controller;
14
+
final int maxLines;
15
+
final bool enabled;
16
+
final TextInputType? keyboardType;
17
+
final String? hintText;
18
+
final void Function(String)? onChanged;
19
+
final Widget? prefixIcon;
20
+
final Widget? suffixIcon;
21
+
final List<Map<String, dynamic>>? facets;
22
+
23
+
const FacetedTextField({
24
+
super.key,
25
+
this.label,
26
+
required this.controller,
27
+
this.maxLines = 1,
28
+
this.enabled = true,
29
+
this.keyboardType,
30
+
this.hintText,
31
+
this.onChanged,
32
+
this.prefixIcon,
33
+
this.suffixIcon,
34
+
this.facets,
35
+
});
36
+
37
+
@override
38
+
ConsumerState<FacetedTextField> createState() => _FacetedTextFieldState();
39
+
}
40
+
41
+
class _FacetedTextFieldState extends ConsumerState<FacetedTextField> {
42
+
// Track which handles have been inserted via overlay selection
43
+
final Set<String> _insertedHandles = {};
44
+
OverlayEntry? _overlayEntry;
45
+
final GlobalKey _fieldKey = GlobalKey();
46
+
List<Profile> _actorResults = [];
47
+
Timer? _debounceTimer;
48
+
49
+
@override
50
+
void initState() {
51
+
super.initState();
52
+
widget.controller.addListener(_onTextChanged);
53
+
}
54
+
55
+
@override
56
+
void dispose() {
57
+
widget.controller.removeListener(_onTextChanged);
58
+
_debounceTimer?.cancel();
59
+
_removeOverlay();
60
+
super.dispose();
61
+
}
62
+
63
+
void _onTextChanged() async {
64
+
final text = widget.controller.text;
65
+
final selection = widget.controller.selection;
66
+
final cursorPos = selection.baseOffset;
67
+
if (cursorPos < 0) {
68
+
_removeOverlay();
69
+
return;
70
+
}
71
+
// If the last character typed is a space, always close overlay
72
+
if (cursorPos > 0 && text[cursorPos - 1] == ' ') {
73
+
_removeOverlay();
74
+
return;
75
+
}
76
+
// Find the @mention match that contains the cursor
77
+
final regex = RegExp(r'@([\w.]+)');
78
+
final matches = regex.allMatches(text);
79
+
String? query;
80
+
for (final match in matches) {
81
+
final start = match.start;
82
+
final end = match.end;
83
+
if (cursorPos > start && cursorPos <= end) {
84
+
query = match.group(1);
85
+
break;
86
+
}
87
+
}
88
+
if (query != null && query.isNotEmpty) {
89
+
_debounceTimer?.cancel();
90
+
_debounceTimer = Timer(const Duration(milliseconds: 500), () async {
91
+
final results = await ref.read(actorSearchProvider.notifier).search(query!);
92
+
if (mounted) {
93
+
setState(() {
94
+
_actorResults = results;
95
+
});
96
+
_showOverlay();
97
+
}
98
+
});
99
+
return;
100
+
}
101
+
_debounceTimer?.cancel();
102
+
_removeOverlay();
103
+
}
104
+
105
+
void _showOverlay() {
106
+
WidgetsBinding.instance.addPostFrameCallback((_) {
107
+
_removeOverlay();
108
+
final overlay = Overlay.of(context);
109
+
final caretOffset = _getCaretPosition();
110
+
if (caretOffset == null) return;
111
+
112
+
// Show only the first 5 results, no scroll, use simple rows
113
+
final double rowHeight = 44.0;
114
+
final int maxItems = 5;
115
+
final resultsToShow = _actorResults.take(maxItems).toList();
116
+
final double overlayHeight = resultsToShow.length * rowHeight;
117
+
final double overlayWidth = 300.0;
118
+
119
+
// Get screen size
120
+
final mediaQuery = MediaQuery.of(context);
121
+
final screenWidth = mediaQuery.size.width;
122
+
123
+
// Default to left of caret, but if it would overflow, switch to right
124
+
double left = caretOffset.dx;
125
+
if (left + overlayWidth > screenWidth - 8) {
126
+
// Try to align right edge of overlay with caret, but don't go off left edge
127
+
left = (caretOffset.dx - overlayWidth).clamp(8.0, screenWidth - overlayWidth - 8.0);
128
+
}
129
+
130
+
_overlayEntry = OverlayEntry(
131
+
builder: (context) => Positioned(
132
+
left: left,
133
+
top: caretOffset.dy,
134
+
width: overlayWidth,
135
+
height: overlayHeight,
136
+
child: Material(
137
+
elevation: 4,
138
+
child: Column(
139
+
mainAxisSize: MainAxisSize.min,
140
+
children: resultsToShow.map((actor) {
141
+
return Material(
142
+
color: Colors.transparent,
143
+
child: InkWell(
144
+
onTap: () => _insertActor(actor.handle),
145
+
child: Container(
146
+
height: rowHeight,
147
+
width: double.infinity,
148
+
alignment: Alignment.centerLeft,
149
+
padding: const EdgeInsets.symmetric(horizontal: 12.0),
150
+
child: Row(
151
+
children: [
152
+
if (actor.avatar != null && actor.avatar!.isNotEmpty)
153
+
CircleAvatar(radius: 16, backgroundImage: NetworkImage(actor.avatar!))
154
+
else
155
+
CircleAvatar(radius: 16, child: Icon(Icons.person, size: 16)),
156
+
const SizedBox(width: 12),
157
+
Expanded(
158
+
child: Text(
159
+
actor.displayName ?? actor.handle,
160
+
style: Theme.of(context).textTheme.bodyMedium,
161
+
overflow: TextOverflow.ellipsis,
162
+
),
163
+
),
164
+
const SizedBox(width: 8),
165
+
Text(
166
+
'@${actor.handle}',
167
+
style: Theme.of(
168
+
context,
169
+
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
170
+
overflow: TextOverflow.ellipsis,
171
+
),
172
+
],
173
+
),
174
+
),
175
+
),
176
+
);
177
+
}).toList(),
178
+
),
179
+
),
180
+
),
181
+
);
182
+
overlay.insert(_overlayEntry!);
183
+
});
184
+
}
185
+
186
+
void _removeOverlay() {
187
+
if (_overlayEntry != null) {
188
+
_overlayEntry?.remove();
189
+
_overlayEntry = null;
190
+
}
191
+
}
192
+
193
+
Offset? _getCaretPosition() {
194
+
final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?;
195
+
if (renderBox == null) return null;
196
+
197
+
final controller = widget.controller;
198
+
final selection = controller.selection;
199
+
if (!selection.isValid) return null;
200
+
201
+
// Get the text up to the caret
202
+
final text = controller.text.substring(0, selection.baseOffset);
203
+
final textStyle =
204
+
Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15) ??
205
+
const TextStyle(fontSize: 15);
206
+
final textPainter = TextPainter(
207
+
text: TextSpan(text: text, style: textStyle),
208
+
textDirection: TextDirection.ltr,
209
+
maxLines: widget.maxLines,
210
+
);
211
+
textPainter.layout(minWidth: 0, maxWidth: renderBox.size.width);
212
+
213
+
final caretOffset = textPainter.getOffsetForCaret(TextPosition(offset: text.length), Rect.zero);
214
+
215
+
// Convert caret offset to global coordinates
216
+
final fieldOffset = renderBox.localToGlobal(Offset.zero);
217
+
// Add vertical padding to position below the caret
218
+
return fieldOffset + Offset(caretOffset.dx, caretOffset.dy + textPainter.preferredLineHeight);
219
+
}
220
+
221
+
void _insertActor(String actorName) {
222
+
final text = widget.controller.text;
223
+
final selection = widget.controller.selection;
224
+
final cursorPos = selection.baseOffset;
225
+
// Find the @mention match that contains the cursor (not just before it)
226
+
final regex = RegExp(r'@([\w.]+)');
227
+
final matches = regex.allMatches(text);
228
+
Match? matchToReplace;
229
+
for (final match in matches) {
230
+
if (cursorPos > match.start && cursorPos <= match.end) {
231
+
matchToReplace = match;
232
+
break;
233
+
}
234
+
}
235
+
if (matchToReplace != null) {
236
+
final start = matchToReplace.start;
237
+
final end = matchToReplace.end;
238
+
final newText = text.replaceRange(start, end, '@$actorName ');
239
+
setState(() {
240
+
_insertedHandles.add(actorName);
241
+
});
242
+
widget.controller.value = TextEditingValue(
243
+
text: newText,
244
+
selection: TextSelection.collapsed(offset: start + actorName.length + 2),
245
+
);
246
+
}
247
+
_removeOverlay();
248
+
}
249
+
250
+
@override
251
+
Widget build(BuildContext context) {
252
+
final theme = Theme.of(context);
253
+
return Column(
254
+
crossAxisAlignment: CrossAxisAlignment.start,
255
+
children: [
256
+
if (widget.label != null && widget.label!.isNotEmpty) ...[
257
+
Text(
258
+
widget.label!,
259
+
style: theme.textTheme.bodyMedium?.copyWith(
260
+
fontWeight: FontWeight.w500,
261
+
color: theme.colorScheme.onSurface,
262
+
),
263
+
),
264
+
const SizedBox(height: 6),
265
+
],
266
+
Container(
267
+
decoration: BoxDecoration(
268
+
color: theme.brightness == Brightness.dark ? Colors.grey[850] : Colors.grey[300],
269
+
borderRadius: BorderRadius.circular(8),
270
+
),
271
+
child: Focus(
272
+
child: Builder(
273
+
builder: (context) {
274
+
final isFocused = Focus.of(context).hasFocus;
275
+
return Stack(
276
+
children: [
277
+
_MentionHighlightTextField(
278
+
key: _fieldKey,
279
+
controller: widget.controller,
280
+
maxLines: widget.maxLines,
281
+
enabled: widget.enabled,
282
+
keyboardType: widget.keyboardType,
283
+
onChanged: widget.onChanged,
284
+
hintText: widget.hintText,
285
+
prefixIcon: widget.prefixIcon,
286
+
suffixIcon: widget.suffixIcon,
287
+
insertedHandles: _insertedHandles,
288
+
facets: widget.facets,
289
+
),
290
+
// Border overlay
291
+
Positioned.fill(
292
+
child: IgnorePointer(
293
+
child: AnimatedContainer(
294
+
duration: const Duration(milliseconds: 150),
295
+
decoration: BoxDecoration(
296
+
border: Border.all(
297
+
color: isFocused ? theme.colorScheme.primary : theme.dividerColor,
298
+
width: isFocused ? 2 : 0,
299
+
),
300
+
borderRadius: BorderRadius.circular(8),
301
+
),
302
+
),
303
+
),
304
+
),
305
+
],
306
+
);
307
+
},
308
+
),
309
+
),
310
+
),
311
+
],
312
+
);
313
+
}
314
+
}
315
+
316
+
class _MentionHighlightTextField extends StatefulWidget {
317
+
final Set<String>? insertedHandles;
318
+
final TextEditingController controller;
319
+
final int maxLines;
320
+
final bool enabled;
321
+
final TextInputType? keyboardType;
322
+
final String? hintText;
323
+
final void Function(String)? onChanged;
324
+
final Widget? prefixIcon;
325
+
final Widget? suffixIcon;
326
+
final List<Map<String, dynamic>>? facets;
327
+
328
+
const _MentionHighlightTextField({
329
+
super.key,
330
+
required this.controller,
331
+
required this.maxLines,
332
+
required this.enabled,
333
+
this.keyboardType,
334
+
this.hintText,
335
+
this.onChanged,
336
+
this.prefixIcon,
337
+
this.suffixIcon,
338
+
this.insertedHandles,
339
+
this.facets,
340
+
});
341
+
342
+
@override
343
+
State<_MentionHighlightTextField> createState() => _MentionHighlightTextFieldState();
344
+
}
345
+
346
+
class _MentionHighlightTextFieldState extends State<_MentionHighlightTextField> {
347
+
final ScrollController _richTextScrollController = ScrollController();
348
+
final ScrollController _textFieldScrollController = ScrollController();
349
+
350
+
void _onMentionTap(String did) {
351
+
// Show overlay for this mention (simulate as if user is typing @mention)
352
+
final parent = context.findAncestorStateOfType<_FacetedTextFieldState>();
353
+
if (parent != null) {
354
+
parent._showOverlay();
355
+
}
356
+
}
357
+
358
+
List<Map<String, dynamic>> _parsedFacets = [];
359
+
Timer? _facetDebounce;
360
+
361
+
@override
362
+
void initState() {
363
+
super.initState();
364
+
_parseFacets();
365
+
widget.controller.addListener(_parseFacets);
366
+
367
+
// Sync scroll controllers
368
+
_textFieldScrollController.addListener(() {
369
+
if (_richTextScrollController.hasClients && _textFieldScrollController.hasClients) {
370
+
_richTextScrollController.jumpTo(_textFieldScrollController.offset);
371
+
}
372
+
});
373
+
}
374
+
375
+
@override
376
+
void dispose() {
377
+
widget.controller.removeListener(_parseFacets);
378
+
_facetDebounce?.cancel();
379
+
_richTextScrollController.dispose();
380
+
_textFieldScrollController.dispose();
381
+
super.dispose();
382
+
}
383
+
384
+
void _parseFacets() {
385
+
_facetDebounce?.cancel();
386
+
_facetDebounce = Timer(const Duration(milliseconds: 100), () async {
387
+
final text = widget.controller.text;
388
+
if (widget.facets != null && widget.facets!.isNotEmpty) {
389
+
setState(() => _parsedFacets = widget.facets!);
390
+
} else {
391
+
try {
392
+
final blueskyText = BlueskyText(text);
393
+
final entities = blueskyText.entities;
394
+
final facets = await entities.toFacets();
395
+
if (mounted) setState(() => _parsedFacets = List<Map<String, dynamic>>.from(facets));
396
+
} catch (_) {
397
+
if (mounted) setState(() => _parsedFacets = []);
398
+
}
399
+
}
400
+
});
401
+
}
402
+
403
+
@override
404
+
Widget build(BuildContext context) {
405
+
final theme = Theme.of(context);
406
+
final text = widget.controller.text;
407
+
final baseStyle = theme.textTheme.bodyMedium?.copyWith(fontSize: 15);
408
+
final linkStyle = baseStyle?.copyWith(color: theme.colorScheme.primary);
409
+
410
+
// Use the same facet processing logic as FacetedText
411
+
final spans = FacetUtils.processFacets(
412
+
text: text,
413
+
facets: _parsedFacets,
414
+
defaultStyle: baseStyle,
415
+
linkStyle: linkStyle,
416
+
onMentionTap: _onMentionTap,
417
+
onLinkTap: null, // No link tap in text field
418
+
onTagTap: null, // No tag tap in text field
419
+
);
420
+
return LayoutBuilder(
421
+
builder: (context, constraints) {
422
+
return SizedBox(
423
+
width: double.infinity, // Make it full width
424
+
height: widget.maxLines == 1
425
+
? null
426
+
: (baseStyle?.fontSize ?? 15) * 1.4 * widget.maxLines +
427
+
24, // Line height * maxLines + padding
428
+
child: Stack(
429
+
children: [
430
+
// RichText for highlight wrapped in SingleChildScrollView
431
+
SingleChildScrollView(
432
+
controller: _richTextScrollController,
433
+
physics: const NeverScrollableScrollPhysics(), // Disable direct interaction
434
+
child: Padding(
435
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
436
+
child: RichText(
437
+
text: TextSpan(children: spans),
438
+
maxLines: null, // Allow unlimited lines for scrolling
439
+
overflow: TextOverflow.visible,
440
+
),
441
+
),
442
+
),
443
+
// Editable TextField for input, but with transparent text so only RichText is visible
444
+
Positioned.fill(
445
+
child: TextField(
446
+
controller: widget.controller,
447
+
scrollController: _textFieldScrollController,
448
+
maxLines: null, // Allow unlimited lines for scrolling
449
+
enabled: widget.enabled,
450
+
keyboardType: widget.keyboardType,
451
+
onChanged: widget.onChanged,
452
+
style: baseStyle?.copyWith(color: const Color(0x01000000)),
453
+
cursorColor: theme.colorScheme.primary,
454
+
showCursor: true,
455
+
enableInteractiveSelection: true,
456
+
decoration: InputDecoration(
457
+
hintText: widget.hintText,
458
+
hintStyle: baseStyle?.copyWith(color: theme.hintColor),
459
+
border: InputBorder.none,
460
+
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
461
+
isDense: true,
462
+
prefixIcon: widget.prefixIcon,
463
+
suffixIcon: widget.suffixIcon,
464
+
),
465
+
),
466
+
),
467
+
],
468
+
),
469
+
);
470
+
},
471
+
);
472
+
}
473
+
}
+20
-1
lib/widgets/gallery_photo_view.dart
+20
-1
lib/widgets/gallery_photo_view.dart
···
128
128
final gallery = widget.gallery;
129
129
final subject = gallery?.uri;
130
130
final focus = photo.uri;
131
-
if (subject == null || focus == null) {
131
+
if (subject == null) {
132
132
return;
133
133
}
134
134
// Use the provider's createComment method
···
165
165
},
166
166
),
167
167
],
168
+
),
169
+
),
170
+
),
171
+
if (!widget.showAddCommentButton && photo.exif != null)
172
+
SafeArea(
173
+
top: false,
174
+
child: Padding(
175
+
padding: const EdgeInsets.all(16),
176
+
child: Align(
177
+
alignment: Alignment.centerRight,
178
+
child: IconButton(
179
+
icon: Icon(Icons.camera_alt, color: Colors.white),
180
+
onPressed: () {
181
+
showDialog(
182
+
context: context,
183
+
builder: (context) => PhotoExifDialog(exif: photo.exif!),
184
+
);
185
+
},
186
+
),
168
187
),
169
188
),
170
189
),
+44
-23
lib/widgets/timeline_item.dart
+44
-23
lib/widgets/timeline_item.dart
···
8
8
import 'package:grain/widgets/faceted_text.dart';
9
9
import 'package:grain/widgets/gallery_action_buttons.dart';
10
10
import 'package:grain/widgets/gallery_preview.dart';
11
+
import 'package:url_launcher/url_launcher.dart';
11
12
12
13
import '../providers/gallery_cache_provider.dart';
13
14
import '../screens/gallery_page.dart';
···
73
74
mainAxisAlignment: MainAxisAlignment.spaceBetween,
74
75
children: [
75
76
Flexible(
76
-
child: Text.rich(
77
-
TextSpan(
78
-
children: [
79
-
if (actor?.displayName?.isNotEmpty ?? false)
80
-
TextSpan(
81
-
text: actor!.displayName ?? '',
82
-
style: theme.textTheme.titleMedium?.copyWith(
83
-
fontWeight: FontWeight.w600,
84
-
fontSize: 16,
77
+
child: GestureDetector(
78
+
onTap:
79
+
onProfileTap ??
80
+
() {
81
+
if (actor?.did != null) {
82
+
Navigator.of(context).push(
83
+
MaterialPageRoute(
84
+
builder: (context) =>
85
+
ProfilePage(did: actor!.did, showAppBar: true),
86
+
),
87
+
);
88
+
}
89
+
},
90
+
child: Text.rich(
91
+
TextSpan(
92
+
children: [
93
+
if (actor?.displayName?.isNotEmpty ?? false)
94
+
TextSpan(
95
+
text: actor!.displayName ?? '',
96
+
style: theme.textTheme.titleMedium?.copyWith(
97
+
fontWeight: FontWeight.w600,
98
+
fontSize: 16,
99
+
),
85
100
),
86
-
),
87
-
if (actor != null && actor.handle.isNotEmpty)
88
-
TextSpan(
89
-
text: (actor.displayName?.isNotEmpty ?? false)
90
-
? ' @${actor.handle}'
91
-
: '@${actor.handle}',
92
-
style: theme.textTheme.bodySmall?.copyWith(
93
-
fontSize: 14,
94
-
color: theme.colorScheme.onSurfaceVariant,
95
-
fontWeight: FontWeight.normal,
101
+
if (actor != null && actor.handle.isNotEmpty)
102
+
TextSpan(
103
+
text: (actor.displayName?.isNotEmpty ?? false)
104
+
? ' @${actor.handle}'
105
+
: '@${actor.handle}',
106
+
style: theme.textTheme.bodySmall?.copyWith(
107
+
fontSize: 14,
108
+
color: theme.colorScheme.onSurfaceVariant,
109
+
fontWeight: FontWeight.normal,
110
+
),
96
111
),
97
-
),
98
-
],
112
+
],
113
+
),
114
+
overflow: TextOverflow.ellipsis,
115
+
maxLines: 1,
99
116
),
100
-
overflow: TextOverflow.ellipsis,
101
-
maxLines: 1,
102
117
),
103
118
),
104
119
Text(
···
159
174
context,
160
175
MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)),
161
176
),
177
+
onLinkTap: (url) async {
178
+
final uri = Uri.parse(url);
179
+
if (!await launchUrl(uri)) {
180
+
throw Exception('Could not launch $url');
181
+
}
182
+
},
162
183
),
163
184
),
164
185
const SizedBox(height: 8),
+104
lib/widgets/upload_progress_overlay.dart
+104
lib/widgets/upload_progress_overlay.dart
···
1
+
import 'dart:io';
2
+
3
+
import 'package:flutter/material.dart';
4
+
5
+
import '../screens/create_gallery_page.dart';
6
+
7
+
class UploadProgressOverlay extends StatelessWidget {
8
+
final List<GalleryImage> images;
9
+
final int currentIndex;
10
+
final double progress; // 0.0 - 1.0
11
+
final bool visible;
12
+
13
+
const UploadProgressOverlay({
14
+
super.key,
15
+
required this.images,
16
+
required this.currentIndex,
17
+
required this.progress,
18
+
this.visible = false,
19
+
});
20
+
21
+
@override
22
+
Widget build(BuildContext context) {
23
+
if (!visible) return const SizedBox.shrink();
24
+
final theme = Theme.of(context);
25
+
26
+
// Get the current image being uploaded
27
+
final currentImage = currentIndex < images.length ? images[currentIndex] : null;
28
+
29
+
// Calculate overall progress: completed images + current image's progress
30
+
double overallProgress = 0.0;
31
+
if (images.isNotEmpty) {
32
+
overallProgress = (currentIndex + progress) / images.length;
33
+
}
34
+
35
+
return Material(
36
+
color: Colors.transparent,
37
+
child: Stack(
38
+
children: [
39
+
Positioned.fill(child: Container(color: Colors.black.withOpacity(0.9))),
40
+
Center(
41
+
child: Padding(
42
+
padding: const EdgeInsets.all(32),
43
+
child: Column(
44
+
mainAxisSize: MainAxisSize.min,
45
+
mainAxisAlignment: MainAxisAlignment.center,
46
+
children: [
47
+
Row(
48
+
mainAxisSize: MainAxisSize.min,
49
+
children: [
50
+
SizedBox(
51
+
width: 24,
52
+
height: 24,
53
+
child: CircularProgressIndicator(
54
+
strokeWidth: 2.5,
55
+
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
56
+
),
57
+
),
58
+
const SizedBox(width: 12),
59
+
Text(
60
+
'Uploading photos...',
61
+
style: theme.textTheme.titleMedium?.copyWith(color: Colors.white),
62
+
),
63
+
],
64
+
),
65
+
const SizedBox(height: 16),
66
+
67
+
// Show current image at true aspect ratio
68
+
if (currentImage != null)
69
+
Container(
70
+
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300),
71
+
child: Image.file(
72
+
File(currentImage.file.path),
73
+
fit: BoxFit.contain, // Maintain aspect ratio
74
+
),
75
+
),
76
+
77
+
const SizedBox(height: 16),
78
+
79
+
// Progress indicator (overall progress)
80
+
SizedBox(
81
+
width: 300,
82
+
child: LinearProgressIndicator(
83
+
value: overallProgress,
84
+
backgroundColor: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
85
+
valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
86
+
),
87
+
),
88
+
89
+
const SizedBox(height: 8),
90
+
91
+
// Position counter and progress percentage
92
+
Text(
93
+
'${currentIndex + 1} of ${images.length} โข ${(overallProgress * 100).toInt()}%',
94
+
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
95
+
),
96
+
],
97
+
),
98
+
),
99
+
),
100
+
],
101
+
),
102
+
);
103
+
}
104
+
}
+6
-6
pubspec.lock
+6
-6
pubspec.lock
···
149
149
dependency: transitive
150
150
description:
151
151
name: built_value
152
-
sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27"
152
+
sha256: "0b1b12a0a549605e5f04476031cd0bc91ead1d7c8e830773a18ee54179b3cb62"
153
153
url: "https://pub.dev"
154
154
source: hosted
155
-
version: "8.10.1"
155
+
version: "8.11.0"
156
156
cached_network_image:
157
157
dependency: "direct main"
158
158
description:
···
309
309
dependency: transitive
310
310
description:
311
311
name: dart_style
312
-
sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af"
312
+
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
313
313
url: "https://pub.dev"
314
314
source: hosted
315
-
version: "3.1.0"
315
+
version: "3.1.1"
316
316
desktop_webview_window:
317
317
dependency: transitive
318
318
description:
···
748
748
dependency: "direct main"
749
749
description:
750
750
name: logger
751
-
sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999"
751
+
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
752
752
url: "https://pub.dev"
753
753
source: hosted
754
-
version: "2.6.0"
754
+
version: "2.6.1"
755
755
logging:
756
756
dependency: transitive
757
757
description:
+2
-2
pubspec.yaml
+2
-2
pubspec.yaml
···
1
1
name: grain
2
-
description: "A new Flutter project."
2
+
description: "Grain Social Mobile App"
3
3
# The following line prevents the package from being accidentally published to
4
4
# pub.dev using `flutter pub publish`. This is preferred for private packages.
5
5
publish_to: "none" # Remove this line if you wish to publish to pub.dev
···
16
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
17
# In Windows, build-name is used as the major, minor, and patch parts
18
18
# of the product and file versions while build-number is used as the build suffix.
19
-
version: 1.0.0+17
19
+
version: 1.0.0+24
20
20
21
21
environment:
22
22
sdk: ^3.8.1