+6
.vscode/settings.json
+6
.vscode/settings.json
+35
-95
lib/api.dart
+35
-95
lib/api.dart
···
1
+
import 'dart:convert';
2
+
import 'dart:io';
3
+
1
4
import 'package:grain/app_logger.dart';
5
+
import 'package:grain/dpop_client.dart';
2
6
import 'package:grain/main.dart';
3
7
import 'package:grain/models/atproto_session.dart';
4
-
import 'models/profile.dart';
5
-
import 'models/gallery.dart';
6
-
import 'models/notification.dart' as grain;
7
-
import './auth.dart';
8
8
import 'package:http/http.dart' as http;
9
-
import 'dart:convert';
10
-
import 'package:grain/dpop_client.dart';
11
-
import 'dart:io';
12
9
import 'package:mime/mime.dart';
10
+
11
+
import './auth.dart';
12
+
import 'models/gallery.dart';
13
+
import 'models/notification.dart' as grain;
14
+
import 'models/profile.dart';
13
15
14
16
class ApiService {
15
17
String? _accessToken;
···
28
30
29
31
final response = await http.get(
30
32
Uri.parse('$_apiUrl/oauth/session'),
31
-
headers: {
32
-
'Authorization': 'Bearer $_accessToken',
33
-
'Content-Type': 'application/json',
34
-
},
33
+
headers: {'Authorization': 'Bearer $_accessToken', 'Content-Type': 'application/json'},
35
34
);
36
35
37
36
if (response.statusCode != 200) {
···
62
61
headers: {'Content-Type': 'application/json'},
63
62
);
64
63
if (response.statusCode != 200) {
65
-
appLogger.w(
66
-
'Failed to fetch profile: ${response.statusCode} ${response.body}',
67
-
);
64
+
appLogger.w('Failed to fetch profile: ${response.statusCode} ${response.body}');
68
65
return null;
69
66
}
70
67
return Profile.fromJson(jsonDecode(response.body));
···
73
70
Future<List<Gallery>> fetchActorGalleries({required String did}) async {
74
71
appLogger.i('Fetching galleries for actor did: $did');
75
72
final response = await http.get(
76
-
Uri.parse(
77
-
'$_apiUrl/xrpc/social.grain.gallery.getActorGalleries?actor=$did',
78
-
),
73
+
Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getActorGalleries?actor=$did'),
79
74
headers: {'Content-Type': 'application/json'},
80
75
);
81
76
if (response.statusCode != 200) {
82
-
appLogger.w(
83
-
'Failed to fetch galleries: ${response.statusCode} ${response.body}',
84
-
);
77
+
appLogger.w('Failed to fetch galleries: ${response.statusCode} ${response.body}');
85
78
return [];
86
79
}
87
80
final json = jsonDecode(response.body);
88
81
galleries =
89
-
(json['items'] as List<dynamic>?)
90
-
?.map((item) => Gallery.fromJson(item))
91
-
.toList() ??
92
-
[];
82
+
(json['items'] as List<dynamic>?)?.map((item) => Gallery.fromJson(item)).toList() ?? [];
93
83
return galleries;
94
84
}
95
85
···
97
87
if (_accessToken == null) {
98
88
return [];
99
89
}
100
-
appLogger.i(
101
-
'Fetching timeline with algorithm: \\${algorithm ?? 'default'}',
102
-
);
90
+
appLogger.i('Fetching timeline with algorithm: \\${algorithm ?? 'default'}');
103
91
final uri = algorithm != null
104
-
? Uri.parse(
105
-
'$_apiUrl/xrpc/social.grain.feed.getTimeline?algorithm=$algorithm',
106
-
)
92
+
? Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline?algorithm=$algorithm')
107
93
: Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline');
108
94
final response = await http.get(
109
95
uri,
110
-
headers: {
111
-
'Authorization': "Bearer $_accessToken",
112
-
'Content-Type': 'application/json',
113
-
},
96
+
headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'},
114
97
);
115
98
if (response.statusCode != 200) {
116
-
appLogger.w(
117
-
'Failed to fetch timeline: ${response.statusCode} ${response.body}',
118
-
);
99
+
appLogger.w('Failed to fetch timeline: ${response.statusCode} ${response.body}');
119
100
return [];
120
101
}
121
102
final json = jsonDecode(response.body);
···
129
110
appLogger.i('Fetching gallery for uri: $uri');
130
111
final response = await http.get(
131
112
Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGallery?uri=$uri'),
132
-
headers: {
133
-
'Authorization': "Bearer $_accessToken",
134
-
'Content-Type': 'application/json',
135
-
},
113
+
headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'},
136
114
);
137
115
if (response.statusCode != 200) {
138
-
appLogger.w(
139
-
'Failed to fetch gallery: ${response.statusCode} ${response.body}',
140
-
);
116
+
appLogger.w('Failed to fetch gallery: ${response.statusCode} ${response.body}');
141
117
return null;
142
118
}
143
119
try {
···
145
121
if (json is Map<String, dynamic>) {
146
122
return Gallery.fromJson(json);
147
123
} else {
148
-
appLogger.w(
149
-
'Unexpected response type for getGallery: ${response.body}',
150
-
);
124
+
appLogger.w('Unexpected response type for getGallery: ${response.body}');
151
125
return null;
152
126
}
153
127
} catch (e, st) {
···
163
137
headers: {'Content-Type': 'application/json'},
164
138
);
165
139
if (response.statusCode != 200) {
166
-
appLogger.w(
167
-
'Failed to fetch gallery thread: ${response.statusCode} ${response.body}',
168
-
);
140
+
appLogger.w('Failed to fetch gallery thread: ${response.statusCode} ${response.body}');
169
141
return {};
170
142
}
171
143
return jsonDecode(response.body) as Map<String, dynamic>;
···
179
151
appLogger.i('Fetching notifications');
180
152
final response = await http.get(
181
153
Uri.parse('$_apiUrl/xrpc/social.grain.notification.getNotifications'),
182
-
headers: {
183
-
'Authorization': "Bearer $_accessToken",
184
-
'Content-Type': 'application/json',
185
-
},
154
+
headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'},
186
155
);
187
156
if (response.statusCode != 200) {
188
-
appLogger.w(
189
-
'Failed to fetch notifications: ${response.statusCode} ${response.body}',
190
-
);
157
+
appLogger.w('Failed to fetch notifications: ${response.statusCode} ${response.body}');
191
158
return [];
192
159
}
193
160
final json = jsonDecode(response.body);
194
161
return (json['notifications'] as List<dynamic>?)
195
-
?.map(
196
-
(item) =>
197
-
grain.Notification.fromJson(item as Map<String, dynamic>),
198
-
)
162
+
?.map((item) => grain.Notification.fromJson(item as Map<String, dynamic>))
199
163
.toList() ??
200
164
[];
201
165
}
···
208
172
appLogger.i('Searching actors with query: $query');
209
173
final response = await http.get(
210
174
Uri.parse('$_apiUrl/xrpc/social.grain.actor.searchActors?q=$query'),
211
-
headers: {
212
-
'Authorization': "Bearer $_accessToken",
213
-
'Content-Type': 'application/json',
214
-
},
175
+
headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'},
215
176
);
216
177
if (response.statusCode != 200) {
217
-
appLogger.w(
218
-
'Failed to search actors: ${response.statusCode} ${response.body}',
219
-
);
178
+
appLogger.w('Failed to search actors: ${response.statusCode} ${response.body}');
220
179
return [];
221
180
}
222
181
final json = jsonDecode(response.body);
223
-
return (json['actors'] as List<dynamic>?)
224
-
?.map((item) => Profile.fromJson(item))
225
-
.toList() ??
226
-
[];
182
+
return (json['actors'] as List<dynamic>?)?.map((item) => Profile.fromJson(item)).toList() ?? [];
227
183
}
228
184
229
185
Future<List<Gallery>> getActorFavs({required String did}) async {
···
233
189
headers: {'Content-Type': 'application/json'},
234
190
);
235
191
if (response.statusCode != 200) {
236
-
appLogger.w(
237
-
'Failed to fetch actor favs: ${response.statusCode} ${response.body}',
238
-
);
192
+
appLogger.w('Failed to fetch actor favs: ${response.statusCode} ${response.body}');
239
193
return [];
240
194
}
241
195
final json = jsonDecode(response.body);
242
-
return (json['items'] as List<dynamic>?)
243
-
?.map((item) => Gallery.fromJson(item))
244
-
.toList() ??
245
-
[];
196
+
return (json['items'] as List<dynamic>?)?.map((item) => Gallery.fromJson(item)).toList() ?? [];
246
197
}
247
198
248
-
Future<String?> createGallery({
249
-
required String title,
250
-
required String description,
251
-
}) async {
199
+
Future<String?> createGallery({required String title, required String description}) async {
252
200
final session = await auth.getValidSession();
253
201
if (session == null) {
254
202
appLogger.w('No valid session for createGallery');
···
277
225
body: jsonEncode(record),
278
226
);
279
227
if (response.statusCode != 200 && response.statusCode != 201) {
280
-
appLogger.w(
281
-
'Failed to create gallery: \\${response.statusCode} \\${response.body}',
282
-
);
228
+
appLogger.w('Failed to create gallery: \\${response.statusCode} \\${response.body}');
283
229
throw Exception('Failed to create gallery: \\${response.statusCode}');
284
230
}
285
231
final result = jsonDecode(response.body) as Map<String, dynamic>;
···
301
247
while (attempts < maxAttempts) {
302
248
gallery = await getGallery(uri: galleryUri);
303
249
if (gallery != null && gallery.items.length == expectedCount) {
304
-
appLogger.i(
305
-
'Gallery $galleryUri has expected number of items: $expectedCount',
306
-
);
250
+
appLogger.i('Gallery $galleryUri has expected number of items: $expectedCount');
307
251
return gallery;
308
252
}
309
253
await Future.delayed(pollDelay);
···
394
338
body: jsonEncode(record),
395
339
);
396
340
if (response.statusCode != 200 && response.statusCode != 201) {
397
-
appLogger.w(
398
-
'Failed to create photo record: \\${response.statusCode} \\${response.body}',
399
-
);
341
+
appLogger.w('Failed to create photo record: \\${response.statusCode} \\${response.body}');
400
342
return null;
401
343
}
402
344
final result = jsonDecode(response.body) as Map<String, dynamic>;
···
437
379
body: jsonEncode(record),
438
380
);
439
381
if (response.statusCode != 200 && response.statusCode != 201) {
440
-
appLogger.w(
441
-
'Failed to create gallery item: \\${response.statusCode} \\${response.body}',
442
-
);
382
+
appLogger.w('Failed to create gallery item: \\${response.statusCode} \\${response.body}');
443
383
return null;
444
384
}
445
385
final result = jsonDecode(response.body) as Map<String, dynamic>;
+1
-4
lib/app_logger.dart
+1
-4
lib/app_logger.dart
+2
-10
lib/app_theme.dart
+2
-10
lib/app_theme.dart
···
14
14
foregroundColor: Colors.black87,
15
15
elevation: 0,
16
16
iconTheme: IconThemeData(color: Colors.black87),
17
-
titleTextStyle: TextStyle(
18
-
color: Colors.black87,
19
-
fontSize: 18,
20
-
fontWeight: FontWeight.w600,
21
-
),
17
+
titleTextStyle: TextStyle(color: Colors.black87, fontSize: 18, fontWeight: FontWeight.w600),
22
18
),
23
19
floatingActionButtonTheme: const FloatingActionButtonThemeData(
24
20
backgroundColor: primaryColor,
···
47
43
foregroundColor: Colors.white,
48
44
elevation: 0,
49
45
iconTheme: IconThemeData(color: Colors.white),
50
-
titleTextStyle: TextStyle(
51
-
color: Colors.white,
52
-
fontSize: 18,
53
-
fontWeight: FontWeight.w600,
54
-
),
46
+
titleTextStyle: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
55
47
),
56
48
floatingActionButtonTheme: const FloatingActionButtonThemeData(
57
49
backgroundColor: primaryColor,
+3
-3
lib/auth.dart
+3
-3
lib/auth.dart
···
1
1
import 'dart:convert';
2
+
2
3
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
4
+
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
3
5
import 'package:grain/api.dart';
4
6
import 'package:grain/app_logger.dart';
5
7
import 'package:grain/main.dart';
6
-
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
7
8
import 'package:grain/models/atproto_session.dart';
8
9
9
10
class Auth {
···
13
14
Future<void> login(String handle) async {
14
15
final apiUrl = AppConfig.apiUrl;
15
16
final redirectedUrl = await FlutterWebAuth2.authenticate(
16
-
url:
17
-
'$apiUrl/oauth/login?client=native&handle=${Uri.encodeComponent(handle)}',
17
+
url: '$apiUrl/oauth/login?client=native&handle=${Uri.encodeComponent(handle)}',
18
18
callbackUrlScheme: 'grainflutter',
19
19
);
20
20
final uri = Uri.parse(redirectedUrl);
+5
-16
lib/dpop_client.dart
+5
-16
lib/dpop_client.dart
···
1
1
import 'dart:convert';
2
+
3
+
import 'package:crypto/crypto.dart';
2
4
import 'package:http/http.dart' as http;
3
5
import 'package:jose/jose.dart';
4
-
import 'package:crypto/crypto.dart';
5
6
import 'package:uuid/uuid.dart';
6
7
7
8
class DpopHttpClient {
···
13
14
/// Extract origin (scheme + host + port) from a URL
14
15
String _extractOrigin(String url) {
15
16
final uri = Uri.parse(url);
16
-
final portPart = (uri.hasPort && uri.port != 80 && uri.port != 443)
17
-
? ':${uri.port}'
18
-
: '';
17
+
final portPart = (uri.hasPort && uri.port != 80 && uri.port != 443) ? ':${uri.port}' : '';
19
18
return '${uri.scheme}://${uri.host}$portPart';
20
19
}
21
20
···
40
39
late Map<String, String> ordered;
41
40
42
41
if (jwk['kty'] == 'EC') {
43
-
ordered = {
44
-
'crv': jwk['crv'],
45
-
'kty': jwk['kty'],
46
-
'x': jwk['x'],
47
-
'y': jwk['y'],
48
-
};
42
+
ordered = {'crv': jwk['crv'], 'kty': jwk['kty'], 'x': jwk['x'], 'y': jwk['y']};
49
43
} else if (jwk['kty'] == 'RSA') {
50
44
ordered = {'e': jwk['e'], 'kty': jwk['kty'], 'n': jwk['n']};
51
45
} else {
···
103
97
final htu = _buildHtu(url.toString());
104
98
final ath = _calculateAth(accessToken);
105
99
106
-
final proof = await _buildProof(
107
-
htm: method.toUpperCase(),
108
-
htu: htu,
109
-
nonce: nonce,
110
-
ath: ath,
111
-
);
100
+
final proof = await _buildProof(htm: method.toUpperCase(), htu: htu, nonce: nonce, ath: ath);
112
101
113
102
// Compose headers, allowing override of Content-Type for raw uploads
114
103
final requestHeaders = <String, String>{
+4
-8
lib/main.dart
+4
-8
lib/main.dart
···
1
1
import 'package:flutter/foundation.dart';
2
2
import 'package:flutter/material.dart';
3
-
import 'package:grain/api.dart';
4
3
import 'package:flutter_dotenv/flutter_dotenv.dart';
5
-
import 'package:google_fonts/google_fonts.dart';
4
+
import 'package:grain/api.dart';
6
5
import 'package:grain/app_logger.dart';
7
-
import 'package:grain/screens/splash_page.dart';
6
+
import 'package:grain/app_theme.dart';
8
7
import 'package:grain/screens/home_page.dart';
9
-
import 'package:grain/app_theme.dart';
8
+
import 'package:grain/screens/splash_page.dart';
10
9
11
10
class AppConfig {
12
11
static late final String apiUrl;
···
16
15
await dotenv.load(fileName: '.env');
17
16
}
18
17
apiUrl = kReleaseMode
19
-
? const String.fromEnvironment(
20
-
'API_URL',
21
-
defaultValue: 'https://grain.social',
22
-
)
18
+
? const String.fromEnvironment('API_URL', defaultValue: 'https://grain.social')
23
19
: dotenv.env['API_URL'] ?? 'http://localhost:8080';
24
20
}
25
21
}
+1
-3
lib/models/comment.dart
+1
-3
lib/models/comment.dart
···
27
27
text: json['text'] ?? '',
28
28
replyTo: json['replyTo'],
29
29
createdAt: json['createdAt'],
30
-
focus: json['focus'] != null
31
-
? GalleryPhoto.fromJson(json['focus'])
32
-
: null,
30
+
focus: json['focus'] != null ? GalleryPhoto.fromJson(json['focus']) : null,
33
31
);
34
32
}
35
33
}
+1
-3
lib/models/gallery.dart
+1
-3
lib/models/gallery.dart
···
34
34
items: (json['items'] as List<dynamic>? ?? [])
35
35
.map((item) => GalleryPhoto.fromJson(item as Map<String, dynamic>))
36
36
.toList(),
37
-
creator: json['creator'] != null
38
-
? Profile.fromJson(json['creator'])
39
-
: null,
37
+
creator: json['creator'] != null ? Profile.fromJson(json['creator']) : null,
40
38
createdAt: json['createdAt'],
41
39
favCount: json['favCount'],
42
40
commentCount: json['commentCount'],
lib/photo_manip.dart
lib/photo_manip.dart
This is a binary file and will not be displayed.
+10
-37
lib/screens/comments_page.dart
+10
-37
lib/screens/comments_page.dart
···
3
3
import 'package:grain/models/comment.dart';
4
4
import 'package:grain/models/gallery.dart';
5
5
import 'package:grain/utils.dart';
6
-
import 'package:grain/widgets/gallery_photo_view.dart';
7
6
import 'package:grain/widgets/app_image.dart';
7
+
import 'package:grain/widgets/gallery_photo_view.dart';
8
8
9
9
class CommentsPage extends StatefulWidget {
10
10
final String galleryUri;
···
73
73
),
74
74
)
75
75
: _error
76
-
? Center(
77
-
child: Text(
78
-
'Failed to load comments.',
79
-
style: theme.textTheme.bodyMedium,
80
-
),
81
-
)
76
+
? Center(child: Text('Failed to load comments.', style: theme.textTheme.bodyMedium))
82
77
: ListView(
83
78
padding: const EdgeInsets.all(12),
84
79
children: [
85
-
if (_gallery != null)
86
-
Text(_gallery!.title, style: theme.textTheme.titleMedium),
80
+
if (_gallery != null) Text(_gallery!.title, style: theme.textTheme.titleMedium),
87
81
const SizedBox(height: 12),
88
82
_CommentsList(
89
83
comments: _comments,
···
128
122
return comments.where((c) => c.replyTo == null).toList();
129
123
}
130
124
131
-
Widget _buildCommentTree(
132
-
Comment comment,
133
-
Map<String, List<Comment>> repliesByParent,
134
-
int depth,
135
-
) {
125
+
Widget _buildCommentTree(Comment comment, Map<String, List<Comment>> repliesByParent, int depth) {
136
126
return Padding(
137
127
padding: EdgeInsets.only(left: depth * 18.0),
138
128
child: Column(
···
166
156
}
167
157
return Column(
168
158
crossAxisAlignment: CrossAxisAlignment.start,
169
-
children: [
170
-
for (final comment in topLevel)
171
-
_buildCommentTree(comment, repliesByParent, 0),
172
-
],
159
+
children: [for (final comment in topLevel) _buildCommentTree(comment, repliesByParent, 0)],
173
160
);
174
161
}
175
162
}
···
190
177
children: [
191
178
if (author['avatar'] != null)
192
179
ClipOval(
193
-
child: AppImage(
194
-
url: author['avatar'],
195
-
width: 32,
196
-
height: 32,
197
-
fit: BoxFit.cover,
198
-
),
180
+
child: AppImage(url: author['avatar'], width: 32, height: 32, fit: BoxFit.cover),
199
181
)
200
182
else
201
183
CircleAvatar(
···
210
192
children: [
211
193
Text(
212
194
author['displayName'] ?? '@${author['handle'] ?? ''}',
213
-
style: theme.textTheme.bodyLarge?.copyWith(
214
-
fontWeight: FontWeight.bold,
215
-
),
195
+
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
216
196
),
217
197
Text(comment.text, style: theme.textTheme.bodyMedium),
218
198
if (comment.focus != null) ...[
···
220
200
Align(
221
201
alignment: Alignment.centerLeft,
222
202
child: ConstrainedBox(
223
-
constraints: const BoxConstraints(
224
-
maxWidth: 180,
225
-
maxHeight: 180,
226
-
),
203
+
constraints: const BoxConstraints(maxWidth: 180, maxHeight: 180),
227
204
child: AspectRatio(
228
-
aspectRatio:
229
-
(comment.focus!.width > 0 &&
230
-
comment.focus!.height > 0)
205
+
aspectRatio: (comment.focus!.width > 0 && comment.focus!.height > 0)
231
206
? comment.focus!.width / comment.focus!.height
232
207
: 1.0,
233
208
child: ClipRRect(
···
261
236
if (comment.createdAt != null)
262
237
Text(
263
238
formatRelativeTime(comment.createdAt!),
264
-
style: theme.textTheme.bodySmall?.copyWith(
265
-
color: theme.hintColor,
266
-
),
239
+
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
267
240
),
268
241
],
269
242
),
+27
-54
lib/screens/create_gallery_page.dart
+27
-54
lib/screens/create_gallery_page.dart
···
1
1
import 'dart:async';
2
2
import 'dart:io';
3
3
import 'dart:ui' as ui;
4
+
5
+
import 'package:flutter/foundation.dart';
4
6
import 'package:flutter/material.dart';
5
7
import 'package:grain/api.dart';
6
-
import 'package:image_picker/image_picker.dart';
7
-
import 'package:grain/widgets/plain_text_field.dart';
8
-
import 'package:grain/widgets/app_button.dart';
9
-
import 'gallery_page.dart';
10
-
import 'package:grain/photo_manip.dart';
11
-
import 'package:flutter/foundation.dart';
12
8
import 'package:grain/models/gallery.dart';
9
+
import 'package:grain/photo_manip.dart';
10
+
import 'package:grain/widgets/app_button.dart';
11
+
import 'package:grain/widgets/plain_text_field.dart';
12
+
import 'package:image_picker/image_picker.dart';
13
13
import 'package:path_provider/path_provider.dart';
14
+
15
+
import 'gallery_page.dart';
14
16
15
17
// Wrapper class for differentiating images
16
18
class GalleryImage {
···
54
56
return tempFile;
55
57
});
56
58
loadedImages.add(
57
-
GalleryImage(
58
-
file: XFile(file.path),
59
-
isExisting: true,
60
-
remoteUri: item.uri,
61
-
),
59
+
GalleryImage(file: XFile(file.path), isExisting: true, remoteUri: item.uri),
62
60
);
63
61
} catch (_) {}
64
62
}
···
76
74
final picked = await picker.pickMultiImage(imageQuality: 85);
77
75
if (picked.isNotEmpty) {
78
76
setState(() {
79
-
_images.addAll(
80
-
picked.map((xfile) => GalleryImage(file: xfile, isExisting: false)),
81
-
);
77
+
_images.addAll(picked.map((xfile) => GalleryImage(file: xfile, isExisting: false)));
82
78
});
83
79
}
84
80
}
···
109
105
// Only upload, create photo, and create gallery item if not existing
110
106
if (!galleryImage.isExisting) {
111
107
final file = File(galleryImage.file.path);
112
-
final resizedResult = await compute<File, ResizeResult>(
113
-
(f) => resizeImage(file: f),
114
-
file,
115
-
);
108
+
final resizedResult = await compute<File, ResizeResult>((f) => resizeImage(file: f), file);
116
109
final blobResult = await apiService.uploadBlob(resizedResult.file);
117
110
if (blobResult != null) {
118
111
final dims = await _getImageDimensions(galleryImage.file);
···
154
147
position++;
155
148
}
156
149
}
157
-
await apiService.pollGalleryItems(
158
-
galleryUri: galleryUri,
159
-
expectedCount: _images.length,
160
-
);
150
+
await apiService.pollGalleryItems(galleryUri: galleryUri, expectedCount: _images.length);
161
151
}
162
152
setState(() => _submitting = false);
163
153
if (mounted && galleryUri != null) {
···
175
165
if (widget.gallery == null) {
176
166
Navigator.of(context).push(
177
167
MaterialPageRoute(
178
-
builder: (context) => GalleryPage(
179
-
uri: galleryUri!,
180
-
currentUserDid: apiService.currentUser?.did,
181
-
),
168
+
builder: (context) =>
169
+
GalleryPage(uri: galleryUri!, currentUserDid: apiService.currentUser?.did),
182
170
),
183
171
);
184
172
}
···
191
179
Widget build(BuildContext context) {
192
180
final theme = Theme.of(context);
193
181
final double maxHeight =
194
-
MediaQuery.of(context).size.height -
195
-
kToolbarHeight -
196
-
MediaQuery.of(context).padding.top;
182
+
MediaQuery.of(context).size.height - kToolbarHeight - MediaQuery.of(context).padding.top;
197
183
198
184
return ConstrainedBox(
199
185
constraints: BoxConstraints(maxHeight: maxHeight),
···
208
194
mainAxisAlignment: MainAxisAlignment.spaceBetween,
209
195
children: [
210
196
TextButton(
211
-
onPressed: _submitting
212
-
? null
213
-
: () => Navigator.of(context).pop(),
214
-
style: TextButton.styleFrom(
215
-
foregroundColor: theme.colorScheme.primary,
216
-
),
197
+
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
198
+
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.primary),
217
199
child: Text('Cancel', style: theme.textTheme.bodyLarge),
218
200
),
219
201
Text(
220
202
widget.gallery == null ? 'New Gallery' : 'Edit Gallery',
221
-
style: theme.textTheme.titleMedium?.copyWith(
222
-
fontWeight: FontWeight.bold,
223
-
),
203
+
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
224
204
),
225
205
AppButton(
226
206
label: widget.gallery == null ? 'Create' : 'Save',
···
237
217
const SizedBox(height: 16),
238
218
Expanded(
239
219
child: SingleChildScrollView(
240
-
padding: EdgeInsets.only(
241
-
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
242
-
),
220
+
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 16),
243
221
child: Column(
244
222
crossAxisAlignment: CrossAxisAlignment.start,
245
223
mainAxisSize: MainAxisSize.min,
···
275
253
GridView.builder(
276
254
shrinkWrap: true,
277
255
physics: const NeverScrollableScrollPhysics(),
278
-
gridDelegate:
279
-
const SliverGridDelegateWithFixedCrossAxisCount(
280
-
crossAxisCount: 3,
281
-
crossAxisSpacing: 8,
282
-
mainAxisSpacing: 8,
283
-
),
256
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
257
+
crossAxisCount: 3,
258
+
crossAxisSpacing: 8,
259
+
mainAxisSpacing: 8,
260
+
),
284
261
itemCount: _images.length,
285
262
itemBuilder: (context, index) {
286
263
final galleryImage = _images[index];
···
289
266
Positioned.fill(
290
267
child: Container(
291
268
decoration: BoxDecoration(
292
-
color: theme
293
-
.colorScheme
294
-
.surfaceContainerHighest,
269
+
color: theme.colorScheme.surfaceContainerHighest,
295
270
borderRadius: BorderRadius.circular(8),
296
271
),
297
272
child: ClipRRect(
···
310
285
child: Container(
311
286
padding: const EdgeInsets.all(2),
312
287
decoration: BoxDecoration(
313
-
color: theme.colorScheme.secondary
314
-
.withOpacity(0.7),
288
+
color: theme.colorScheme.secondary.withOpacity(0.7),
315
289
borderRadius: BorderRadius.circular(4),
316
290
),
317
291
child: Icon(
···
328
302
onTap: () => _removeImage(index),
329
303
child: Container(
330
304
decoration: BoxDecoration(
331
-
color: theme.colorScheme.surfaceTint
332
-
.withOpacity(0.7),
305
+
color: theme.colorScheme.surfaceTint.withOpacity(0.7),
333
306
shape: BoxShape.circle,
334
307
),
335
308
child: Icon(
+9
-25
lib/screens/explore_page.dart
+9
-25
lib/screens/explore_page.dart
···
1
1
import 'dart:async';
2
+
2
3
import 'package:flutter/material.dart';
3
4
import 'package:grain/api.dart';
4
5
import 'package:grain/models/profile.dart';
5
6
import 'package:grain/widgets/app_image.dart';
6
7
import 'package:grain/widgets/plain_text_field.dart';
8
+
7
9
import 'profile_page.dart';
8
10
9
11
class ExplorePage extends StatefulWidget {
···
108
110
leading: SizedBox(
109
111
width: 20,
110
112
height: 20,
111
-
child: CircularProgressIndicator(
112
-
strokeWidth: 2,
113
-
color: theme.colorScheme.primary,
114
-
),
113
+
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
115
114
),
116
115
);
117
116
} else if (_searched && results.isEmpty) {
118
-
return ListTile(
119
-
title: Text('No users found', style: theme.textTheme.bodyMedium),
120
-
);
117
+
return ListTile(title: Text('No users found', style: theme.textTheme.bodyMedium));
121
118
}
122
119
return ListView.separated(
123
120
itemCount: results.length,
···
128
125
return ListTile(
129
126
leading: profile.avatar.isNotEmpty
130
127
? ClipOval(
131
-
child: AppImage(
132
-
url: profile.avatar,
133
-
width: 32,
134
-
height: 32,
135
-
fit: BoxFit.cover,
136
-
),
128
+
child: AppImage(url: profile.avatar, width: 32, height: 32, fit: BoxFit.cover),
137
129
)
138
130
: CircleAvatar(
139
131
radius: 16,
140
132
backgroundColor: theme.colorScheme.surfaceContainerHighest,
141
-
child: Icon(
142
-
Icons.account_circle,
143
-
color: theme.iconTheme.color,
144
-
),
133
+
child: Icon(Icons.account_circle, color: theme.iconTheme.color),
145
134
),
146
135
title: Text(
147
-
profile.displayName.isNotEmpty
148
-
? profile.displayName
149
-
: '@${profile.handle}',
136
+
profile.displayName.isNotEmpty ? profile.displayName : '@${profile.handle}',
150
137
style: theme.textTheme.bodyLarge,
151
138
),
152
139
subtitle: profile.handle.isNotEmpty
153
140
? Text(
154
141
'@${profile.handle}',
155
-
style: theme.textTheme.bodyMedium?.copyWith(
156
-
color: theme.hintColor,
157
-
),
142
+
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
158
143
)
159
144
: null,
160
145
onTap: () async {
···
167
152
if (context.mounted) {
168
153
Navigator.of(context).push(
169
154
MaterialPageRoute(
170
-
builder: (context) =>
171
-
ProfilePage(did: profile.did, showAppBar: true),
155
+
builder: (context) => ProfilePage(did: profile.did, showAppBar: true),
172
156
),
173
157
);
174
158
}
+28
-60
lib/screens/gallery_page.dart
+28
-60
lib/screens/gallery_page.dart
···
1
+
import 'package:at_uri/at_uri.dart';
2
+
import 'package:cached_network_image/cached_network_image.dart';
1
3
import 'package:flutter/material.dart';
4
+
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
5
+
import 'package:grain/api.dart';
2
6
import 'package:grain/app_theme.dart';
3
7
import 'package:grain/models/gallery.dart';
4
-
import 'package:grain/api.dart';
8
+
import 'package:grain/screens/create_gallery_page.dart';
9
+
import 'package:grain/widgets/app_image.dart';
10
+
import 'package:grain/widgets/gallery_photo_view.dart';
5
11
import 'package:grain/widgets/justified_gallery_view.dart';
12
+
import 'package:share_plus/share_plus.dart';
13
+
6
14
import './comments_page.dart';
7
-
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
8
-
import 'package:grain/widgets/gallery_photo_view.dart';
9
-
import 'package:at_uri/at_uri.dart';
10
-
import 'package:share_plus/share_plus.dart';
11
-
import 'package:grain/widgets/app_image.dart';
12
-
import 'package:cached_network_image/cached_network_image.dart';
13
-
import 'package:grain/screens/create_gallery_page.dart';
14
15
15
16
class GalleryPage extends StatefulWidget {
16
17
final String uri;
···
73
74
return Scaffold(
74
75
backgroundColor: theme.scaffoldBackgroundColor,
75
76
body: const Center(
76
-
child: CircularProgressIndicator(
77
-
strokeWidth: 2,
78
-
color: Color(0xFF0EA5E9),
79
-
),
77
+
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0EA5E9)),
80
78
),
81
79
);
82
80
}
···
88
86
}
89
87
final gallery = _gallery!;
90
88
final isLoggedIn = widget.currentUserDid != null;
91
-
final galleryItems = gallery.items
92
-
.where((item) => item.thumb.isNotEmpty)
93
-
.toList();
89
+
final galleryItems = gallery.items.where((item) => item.thumb.isNotEmpty).toList();
94
90
final isFav = gallery.viewer != null && gallery.viewer!['fav'] != null;
95
91
96
92
return Stack(
···
106
102
),
107
103
title: Text(
108
104
'Gallery',
109
-
style: theme.textTheme.titleMedium?.copyWith(
110
-
fontWeight: FontWeight.w600,
111
-
),
105
+
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
112
106
),
113
107
iconTheme: theme.appBarTheme.iconTheme,
114
108
titleTextStyle: theme.appBarTheme.titleTextStyle,
···
138
132
const SizedBox(height: 10),
139
133
Text(
140
134
gallery.title.isNotEmpty ? gallery.title : 'Gallery',
141
-
style: theme.textTheme.headlineSmall?.copyWith(
142
-
fontWeight: FontWeight.w600,
143
-
),
135
+
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
144
136
),
145
137
const SizedBox(height: 10),
146
138
Row(
···
148
140
children: [
149
141
CircleAvatar(
150
142
radius: 18,
151
-
backgroundColor:
152
-
theme.colorScheme.surfaceContainerHighest,
143
+
backgroundColor: theme.colorScheme.surfaceContainerHighest,
153
144
backgroundImage:
154
-
gallery.creator?.avatar != null &&
155
-
gallery.creator!.avatar.isNotEmpty
145
+
gallery.creator?.avatar != null && gallery.creator!.avatar.isNotEmpty
156
146
? null
157
147
: null,
158
-
child:
159
-
(gallery.creator == null ||
160
-
gallery.creator!.avatar.isEmpty)
148
+
child: (gallery.creator == null || gallery.creator!.avatar.isEmpty)
161
149
? Icon(
162
150
Icons.account_circle,
163
151
size: 24,
164
-
color: theme.colorScheme.onSurface
165
-
.withOpacity(0.4),
152
+
color: theme.colorScheme.onSurface.withOpacity(0.4),
166
153
)
167
154
: ClipOval(
168
155
child: AppImage(
···
184
171
fontWeight: FontWeight.w600,
185
172
),
186
173
),
187
-
if ((gallery.creator?.displayName ?? '')
188
-
.isNotEmpty &&
174
+
if ((gallery.creator?.displayName ?? '').isNotEmpty &&
189
175
(gallery.creator?.handle ?? '').isNotEmpty)
190
176
const SizedBox(width: 8),
191
177
Text(
192
178
'@${gallery.creator?.handle ?? ''}',
193
-
style: theme.textTheme.bodyMedium?.copyWith(
194
-
color: theme.hintColor,
195
-
),
179
+
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
196
180
),
197
181
],
198
182
),
···
205
189
const SizedBox(height: 12),
206
190
if (gallery.description.isNotEmpty)
207
191
Padding(
208
-
padding: const EdgeInsets.symmetric(
209
-
horizontal: 8,
210
-
).copyWith(bottom: 8),
192
+
padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 8),
211
193
child: Text(
212
194
gallery.description,
213
-
style: theme.textTheme.bodyMedium?.copyWith(
214
-
color: theme.colorScheme.onSurface,
215
-
),
195
+
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
216
196
),
217
197
),
218
198
if (isLoggedIn)
···
235
215
mainAxisAlignment: MainAxisAlignment.center,
236
216
children: [
237
217
FaIcon(
238
-
isFav
239
-
? FontAwesomeIcons.solidHeart
240
-
: FontAwesomeIcons.heart,
241
-
color: isFav
242
-
? AppTheme.favoriteColor
243
-
: theme.iconTheme.color,
218
+
isFav ? FontAwesomeIcons.solidHeart : FontAwesomeIcons.heart,
219
+
color: isFav ? AppTheme.favoriteColor : theme.iconTheme.color,
244
220
size: 20,
245
221
),
246
222
if (gallery.favCount != null) ...[
···
265
241
onTap: () {
266
242
Navigator.of(context).push(
267
243
MaterialPageRoute(
268
-
builder: (context) =>
269
-
CommentsPage(galleryUri: gallery.uri),
244
+
builder: (context) => CommentsPage(galleryUri: gallery.uri),
270
245
),
271
246
);
272
247
},
···
307
282
final atUri = AtUri.parse(gallery.uri);
308
283
final handle = gallery.creator?.handle ?? '';
309
284
final galleryRkey = atUri.rkey;
310
-
final url =
311
-
'https://grain.social/profile/$handle/gallery/$galleryRkey';
312
-
final shareText =
313
-
"Check out this gallery on @grain.social \n$url";
314
-
SharePlus.instance.share(
315
-
ShareParams(text: shareText),
316
-
);
285
+
final url = 'https://grain.social/profile/$handle/gallery/$galleryRkey';
286
+
final shareText = "Check out this gallery on @grain.social \n$url";
287
+
SharePlus.instance.share(ShareParams(text: shareText));
317
288
},
318
289
child: Container(
319
290
padding: const EdgeInsets.symmetric(vertical: 8),
···
350
321
),
351
322
if (galleryItems.isEmpty)
352
323
Center(
353
-
child: Text(
354
-
'No photos in this gallery.',
355
-
style: theme.textTheme.bodyMedium,
356
-
),
324
+
child: Text('No photos in this gallery.', style: theme.textTheme.bodyMedium),
357
325
),
358
326
],
359
327
),
+32
-82
lib/screens/home_page.dart
+32
-82
lib/screens/home_page.dart
···
1
1
import 'package:flutter/material.dart';
2
+
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
2
3
import 'package:grain/api.dart';
3
4
import 'package:grain/models/gallery.dart';
4
-
import 'package:grain/widgets/timeline_item.dart';
5
+
import 'package:grain/screens/create_gallery_page.dart';
6
+
import 'package:grain/widgets/app_image.dart';
5
7
import 'package:grain/widgets/app_version_text.dart';
6
8
import 'package:grain/widgets/bottom_nav_bar.dart';
7
-
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
9
+
import 'package:grain/widgets/timeline_item.dart';
10
+
8
11
import 'explore_page.dart';
9
12
import 'log_page.dart';
10
13
import 'notifications_page.dart';
11
14
import 'profile_page.dart';
12
-
import 'package:grain/widgets/app_image.dart';
13
-
import 'package:grain/screens/create_gallery_page.dart';
14
15
15
16
class TimelineItem {
16
17
final Gallery gallery;
···
55
56
try {
56
57
final galleries = await apiService.getTimeline(algorithm: algorithm);
57
58
setState(() {
58
-
_followingTimeline = galleries
59
-
.map((g) => TimelineItem.fromGallery(g))
60
-
.toList();
59
+
_followingTimeline = galleries.map((g) => TimelineItem.fromGallery(g)).toList();
61
60
_followingTimelineLoading = false;
62
61
});
63
62
} catch (e) {
···
73
72
try {
74
73
final galleries = await apiService.getTimeline(algorithm: algorithm);
75
74
setState(() {
76
-
_timeline = galleries
77
-
.map((g) => TimelineItem.fromGallery(g))
78
-
.toList();
75
+
_timeline = galleries.map((g) => TimelineItem.fromGallery(g)).toList();
79
76
_timelineLoading = false;
80
77
});
81
78
} catch (e) {
···
100
97
101
98
void _initTabController() {
102
99
_tabController?.dispose();
103
-
_tabController = TabController(
104
-
length: 2,
105
-
vsync: this,
106
-
initialIndex: _tabIndex,
107
-
);
100
+
_tabController = TabController(length: 2, vsync: this, initialIndex: _tabIndex);
108
101
_tabController!.addListener(() {
109
102
if (_tabController!.index != _tabIndex) {
110
103
_onTabChanged(_tabController!.index);
···
139
132
return CustomScrollView(
140
133
key: PageStorageKey(following ? 'following' : 'timeline'),
141
134
slivers: [
142
-
SliverOverlapInjector(
143
-
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
144
-
),
135
+
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
145
136
if (timeline.isEmpty && loading)
146
137
SliverFillRemaining(
147
138
hasScrollBody: false,
···
156
147
SliverFillRemaining(
157
148
hasScrollBody: false,
158
149
child: Center(
159
-
child: Text(
160
-
following
161
-
? 'No following timeline items.'
162
-
: 'No timeline items.',
163
-
),
150
+
child: Text(following ? 'No following timeline items.' : 'No timeline items.'),
164
151
),
165
152
),
166
153
if (timeline.isNotEmpty)
···
180
167
if (apiService.currentUser == null) {
181
168
return Scaffold(
182
169
body: Center(
183
-
child: CircularProgressIndicator(
184
-
strokeWidth: 2,
185
-
color: theme.colorScheme.primary,
186
-
),
170
+
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
187
171
),
188
172
);
189
173
}
···
199
183
height: 250,
200
184
decoration: BoxDecoration(
201
185
color: theme.scaffoldBackgroundColor,
202
-
border: Border(
203
-
bottom: BorderSide(color: theme.dividerColor, width: 1),
204
-
),
186
+
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)),
205
187
),
206
188
padding: const EdgeInsets.fromLTRB(16, 115, 16, 16),
207
189
child: Column(
···
231
213
if (apiService.currentUser?.handle != null)
232
214
Text(
233
215
'@${apiService.currentUser!.handle}',
234
-
style: theme.textTheme.bodySmall?.copyWith(
235
-
color: theme.hintColor,
236
-
),
216
+
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
237
217
),
238
218
const SizedBox(height: 6),
239
219
Row(
240
220
mainAxisAlignment: MainAxisAlignment.start,
241
221
children: [
242
222
Text(
243
-
(apiService.currentUser?.followersCount ?? 0)
244
-
.toString(),
223
+
(apiService.currentUser?.followersCount ?? 0).toString(),
245
224
style: theme.textTheme.bodyMedium?.copyWith(
246
225
fontWeight: FontWeight.bold,
247
226
color: theme.colorScheme.onSurface,
···
250
229
const SizedBox(width: 4),
251
230
Text(
252
231
'Followers',
253
-
style: theme.textTheme.bodySmall?.copyWith(
254
-
color: theme.hintColor,
255
-
),
232
+
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
256
233
),
257
234
const SizedBox(width: 16),
258
235
Text(
259
-
(apiService.currentUser?.followsCount ?? 0)
260
-
.toString(),
236
+
(apiService.currentUser?.followsCount ?? 0).toString(),
261
237
style: theme.textTheme.bodyMedium?.copyWith(
262
238
fontWeight: FontWeight.bold,
263
239
color: theme.colorScheme.onSurface,
···
266
242
const SizedBox(width: 4),
267
243
Text(
268
244
'Following',
269
-
style: theme.textTheme.bodySmall?.copyWith(
270
-
color: theme.hintColor,
271
-
),
245
+
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
272
246
),
273
247
],
274
248
),
···
316
290
title: const Text('Logs'),
317
291
onTap: () {
318
292
Navigator.pop(context);
319
-
Navigator.of(context).push(
320
-
MaterialPageRoute(builder: (context) => const LogPage()),
321
-
);
293
+
Navigator.of(
294
+
context,
295
+
).push(MaterialPageRoute(builder: (context) => const LogPage()));
322
296
},
323
297
),
324
298
const SizedBox(height: 16),
···
341
315
snap: false,
342
316
pinned: true,
343
317
elevation: 0.5,
344
-
title: Text(
345
-
widget.title,
346
-
style: theme.appBarTheme.titleTextStyle,
347
-
),
318
+
title: Text(widget.title, style: theme.appBarTheme.titleTextStyle),
348
319
leading: Builder(
349
320
builder: (context) => IconButton(
350
321
icon: const Icon(Icons.menu),
···
366
337
dividerColor: theme.dividerColor,
367
338
controller: _tabController,
368
339
indicator: UnderlineTabIndicator(
369
-
borderSide: BorderSide(
370
-
color: theme.colorScheme.primary,
371
-
width: 3,
372
-
),
340
+
borderSide: BorderSide(color: theme.colorScheme.primary, width: 3),
373
341
insets: EdgeInsets.zero,
374
342
),
375
343
indicatorSize: TabBarIndicatorSize.tab,
376
344
labelColor: theme.colorScheme.onSurface,
377
345
unselectedLabelColor: theme.colorScheme.onSurfaceVariant,
378
-
labelStyle: const TextStyle(
379
-
fontWeight: FontWeight.w600,
380
-
fontSize: 16,
381
-
),
346
+
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
382
347
tabs: const [
383
348
Tab(text: 'Timeline'),
384
349
Tab(text: 'Following'),
···
393
358
controller: _tabController,
394
359
physics: const NeverScrollableScrollPhysics(),
395
360
children: [
396
-
Builder(
397
-
builder: (context) =>
398
-
_buildTimelineSliver(context, following: false),
399
-
),
400
-
Builder(
401
-
builder: (context) =>
402
-
_buildTimelineSliver(context, following: true),
403
-
),
361
+
Builder(builder: (context) => _buildTimelineSliver(context, following: false)),
362
+
Builder(builder: (context) => _buildTimelineSliver(context, following: true)),
404
363
],
405
364
),
406
365
),
···
436
395
},
437
396
avatarUrl: apiService.currentUser?.avatar,
438
397
),
439
-
floatingActionButton:
440
-
(!showProfile && !showNotifications && !showExplore)
398
+
floatingActionButton: (!showProfile && !showNotifications && !showExplore)
441
399
? FloatingActionButton(
442
400
shape: const CircleBorder(),
443
401
onPressed: () {
···
464
422
Container(
465
423
decoration: BoxDecoration(
466
424
color: theme.scaffoldBackgroundColor,
467
-
border: Border(
468
-
bottom: BorderSide(color: theme.dividerColor, width: 1),
469
-
),
425
+
border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)),
470
426
),
471
427
padding: const EdgeInsets.fromLTRB(16, 24, 16, 16),
472
428
child: Column(
···
507
463
mainAxisAlignment: MainAxisAlignment.start,
508
464
children: [
509
465
Text(
510
-
(apiService.currentUser?.followersCount ?? 0)
511
-
.toString(),
466
+
(apiService.currentUser?.followersCount ?? 0).toString(),
512
467
style: theme.textTheme.bodyMedium?.copyWith(
513
468
fontWeight: FontWeight.bold,
514
469
fontSize: 13,
···
586
541
title: const Text('Logs'),
587
542
onTap: () {
588
543
Navigator.pop(context);
589
-
Navigator.of(context).push(
590
-
MaterialPageRoute(builder: (context) => const LogPage()),
591
-
);
544
+
Navigator.of(
545
+
context,
546
+
).push(MaterialPageRoute(builder: (context) => const LogPage()));
592
547
},
593
548
),
594
549
const SizedBox(height: 16),
···
645
600
color: theme.scaffoldBackgroundColor.withOpacity(0.98),
646
601
child: SafeArea(
647
602
child: Stack(
648
-
children: [
649
-
ProfilePage(
650
-
did: apiService.currentUser?.did,
651
-
showAppBar: false,
652
-
),
653
-
],
603
+
children: [ProfilePage(did: apiService.currentUser?.did, showAppBar: false)],
654
604
),
655
605
),
656
606
),
+6
-24
lib/screens/notifications_page.dart
+6
-24
lib/screens/notifications_page.dart
···
77
77
return ListTile(
78
78
leading: CircleAvatar(
79
79
backgroundColor: theme.colorScheme.surfaceVariant,
80
-
backgroundImage: author.avatar.isNotEmpty
81
-
? NetworkImage(author.avatar)
82
-
: null,
80
+
backgroundImage: author.avatar.isNotEmpty ? NetworkImage(author.avatar) : null,
83
81
child: author.avatar.isEmpty
84
82
? Icon(Icons.account_circle, color: theme.iconTheme.color)
85
83
: null,
86
84
),
87
85
title: Text(
88
-
author.displayName.isNotEmpty
89
-
? author.displayName
90
-
: '@${author.handle}',
86
+
author.displayName.isNotEmpty ? author.displayName : '@${author.handle}',
91
87
style: theme.textTheme.bodyLarge,
92
88
),
93
89
subtitle: Text(
···
107
103
backgroundColor: theme.scaffoldBackgroundColor,
108
104
body: _loading
109
105
? Center(
110
-
child: CircularProgressIndicator(
111
-
strokeWidth: 2,
112
-
color: theme.colorScheme.primary,
113
-
),
106
+
child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
114
107
)
115
108
: _error
116
-
? Center(
117
-
child: Text(
118
-
'Failed to load notifications.',
119
-
style: theme.textTheme.bodyMedium,
120
-
),
121
-
)
109
+
? Center(child: Text('Failed to load notifications.', style: theme.textTheme.bodyMedium))
122
110
: _notifications.isEmpty
123
-
? Center(
124
-
child: Text(
125
-
'No notifications yet.',
126
-
style: theme.textTheme.bodyMedium,
127
-
),
128
-
)
111
+
? Center(child: Text('No notifications yet.', style: theme.textTheme.bodyMedium))
129
112
: ListView.separated(
130
113
itemCount: _notifications.length,
131
-
separatorBuilder: (context, index) =>
132
-
Divider(height: 1, color: theme.dividerColor),
114
+
separatorBuilder: (context, index) => Divider(height: 1, color: theme.dividerColor),
133
115
itemBuilder: (context, index) {
134
116
final notification = _notifications[index];
135
117
return _buildNotificationTile(notification);
+41
-100
lib/screens/profile_page.dart
+41
-100
lib/screens/profile_page.dart
···
1
1
import 'package:flutter/material.dart';
2
+
import 'package:grain/api.dart';
3
+
import 'package:grain/app_theme.dart';
2
4
import 'package:grain/models/gallery.dart';
3
-
import 'package:grain/api.dart';
5
+
import 'package:grain/widgets/app_image.dart';
6
+
4
7
import 'gallery_page.dart';
5
-
import 'package:grain/widgets/app_image.dart';
6
-
import 'package:grain/app_theme.dart';
7
8
8
9
class ProfilePage extends StatefulWidget {
9
10
final dynamic profile;
10
11
final String? did;
11
12
final bool showAppBar;
12
-
const ProfilePage({
13
-
super.key,
14
-
this.profile,
15
-
this.did,
16
-
this.showAppBar = false,
17
-
});
13
+
const ProfilePage({super.key, this.profile, this.did, this.showAppBar = false});
18
14
19
15
@override
20
16
State<ProfilePage> createState() => _ProfilePageState();
21
17
}
22
18
23
-
class _ProfilePageState extends State<ProfilePage>
24
-
with SingleTickerProviderStateMixin {
19
+
class _ProfilePageState extends State<ProfilePage> with SingleTickerProviderStateMixin {
25
20
dynamic _profile;
26
21
bool _loading = true;
27
22
List<Gallery> _galleries = [];
···
116
111
return Scaffold(
117
112
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
118
113
body: const Center(
119
-
child: CircularProgressIndicator(
120
-
strokeWidth: 2,
121
-
color: AppTheme.primaryColor,
122
-
),
114
+
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryColor),
123
115
),
124
116
);
125
117
}
···
171
163
else
172
164
const Align(
173
165
alignment: Alignment.centerLeft,
174
-
child: Icon(
175
-
Icons.account_circle,
176
-
size: 64,
177
-
color: Colors.grey,
178
-
),
166
+
child: Icon(Icons.account_circle, size: 64, color: Colors.grey),
179
167
),
180
168
const SizedBox(height: 8),
181
169
Text(
182
170
profile.displayName ?? '',
183
-
style: const TextStyle(
184
-
fontSize: 28,
185
-
fontWeight: FontWeight.w800,
186
-
),
171
+
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w800),
187
172
textAlign: TextAlign.left,
188
173
),
189
174
const SizedBox(height: 2),
···
191
176
'@${profile.handle ?? ''}',
192
177
style: TextStyle(
193
178
fontSize: 14,
194
-
color:
195
-
Theme.of(context).brightness ==
196
-
Brightness.dark
179
+
color: Theme.of(context).brightness == Brightness.dark
197
180
? Colors.grey[400]
198
181
: Colors.grey[700],
199
182
),
···
205
188
(profile.followersCount is int
206
189
? profile.followersCount
207
190
: int.tryParse(
208
-
profile.followersCount
209
-
?.toString() ??
210
-
'0',
191
+
profile.followersCount?.toString() ?? '0',
211
192
) ??
212
193
0)
213
194
.toString(),
···
215
196
(profile.followsCount is int
216
197
? profile.followsCount
217
198
: int.tryParse(
218
-
profile.followsCount
219
-
?.toString() ??
220
-
'0',
199
+
profile.followsCount?.toString() ?? '0',
221
200
) ??
222
201
0)
223
202
.toString(),
···
225
204
(profile.galleryCount is int
226
205
? profile.galleryCount
227
206
: int.tryParse(
228
-
profile.galleryCount
229
-
?.toString() ??
230
-
'0',
207
+
profile.galleryCount?.toString() ?? '0',
231
208
) ??
232
209
0)
233
210
.toString(),
234
211
),
235
212
if ((profile.description ?? '').isNotEmpty) ...[
236
213
const SizedBox(height: 16),
237
-
Text(
238
-
profile.description,
239
-
textAlign: TextAlign.left,
240
-
),
214
+
Text(profile.description, textAlign: TextAlign.left),
241
215
],
242
216
const SizedBox(height: 24),
243
217
],
···
250
224
dividerColor: theme.disabledColor,
251
225
controller: _tabController,
252
226
indicator: UnderlineTabIndicator(
253
-
borderSide: const BorderSide(
254
-
color: AppTheme.primaryColor,
255
-
width: 3,
256
-
),
227
+
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 3),
257
228
insets: EdgeInsets.zero,
258
229
),
259
230
indicatorSize: TabBarIndicatorSize.tab,
260
231
labelColor: theme.colorScheme.onSurface,
261
-
unselectedLabelColor:
262
-
theme.colorScheme.onSurfaceVariant,
263
-
labelStyle: const TextStyle(
264
-
fontWeight: FontWeight.w600,
265
-
fontSize: 16,
266
-
),
232
+
unselectedLabelColor: theme.colorScheme.onSurfaceVariant,
233
+
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
267
234
tabs: [
268
235
const Tab(text: 'Galleries'),
269
236
if (apiService.currentUser?.did == profile.did)
···
290
257
? const Center(child: Text('No galleries yet'))
291
258
: GridView.builder(
292
259
padding: EdgeInsets.zero,
293
-
gridDelegate:
294
-
const SliverGridDelegateWithFixedCrossAxisCount(
295
-
crossAxisCount: 3,
296
-
childAspectRatio: 3 / 4,
297
-
crossAxisSpacing: 2,
298
-
mainAxisSpacing: 2,
299
-
),
300
-
itemCount: (_galleries.length < 12
301
-
? 12
302
-
: _galleries.length),
260
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
261
+
crossAxisCount: 3,
262
+
childAspectRatio: 3 / 4,
263
+
crossAxisSpacing: 2,
264
+
mainAxisSpacing: 2,
265
+
),
266
+
itemCount: (_galleries.length < 12 ? 12 : _galleries.length),
303
267
itemBuilder: (context, index) {
304
-
if (_galleries.isNotEmpty &&
305
-
index < _galleries.length) {
268
+
if (_galleries.isNotEmpty && index < _galleries.length) {
306
269
final gallery = _galleries[index];
307
270
final hasPhoto =
308
-
gallery.items.isNotEmpty &&
309
-
gallery.items[0].thumb.isNotEmpty;
271
+
gallery.items.isNotEmpty && gallery.items[0].thumb.isNotEmpty;
310
272
return GestureDetector(
311
273
onTap: () {
312
274
if (gallery.uri.isNotEmpty) {
···
322
284
},
323
285
child: Container(
324
286
decoration: BoxDecoration(
325
-
color: Theme.of(
326
-
context,
327
-
).colorScheme.surfaceContainerHighest,
287
+
color: Theme.of(context).colorScheme.surfaceContainerHighest,
328
288
),
329
289
clipBehavior: Clip.antiAlias,
330
290
child: hasPhoto
331
-
? AppImage(
332
-
url: gallery.items[0].thumb,
333
-
fit: BoxFit.cover,
334
-
)
291
+
? AppImage(url: gallery.items[0].thumb, fit: BoxFit.cover)
335
292
: Center(
336
293
child: Text(
337
294
gallery.title,
338
295
style: TextStyle(
339
296
fontSize: 12,
340
-
color: theme
341
-
.colorScheme
342
-
.onSurfaceVariant,
297
+
color: theme.colorScheme.onSurfaceVariant,
343
298
),
344
299
textAlign: TextAlign.center,
345
300
),
···
348
303
);
349
304
}
350
305
// Placeholder for empty slots
351
-
return Container(
352
-
color:
353
-
theme.colorScheme.surfaceContainerHighest,
354
-
);
306
+
return Container(color: theme.colorScheme.surfaceContainerHighest);
355
307
},
356
308
),
357
309
// Favs tab
···
367
319
? const Center(child: Text('No favorites yet'))
368
320
: GridView.builder(
369
321
padding: EdgeInsets.zero,
370
-
gridDelegate:
371
-
const SliverGridDelegateWithFixedCrossAxisCount(
372
-
crossAxisCount: 3,
373
-
childAspectRatio: 3 / 4,
374
-
crossAxisSpacing: 2,
375
-
mainAxisSpacing: 2,
376
-
),
322
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
323
+
crossAxisCount: 3,
324
+
childAspectRatio: 3 / 4,
325
+
crossAxisSpacing: 2,
326
+
mainAxisSpacing: 2,
327
+
),
377
328
itemCount: _favs.length,
378
329
itemBuilder: (context, index) {
379
330
final gallery = _favs[index];
380
331
final hasPhoto =
381
-
gallery.items.isNotEmpty &&
382
-
gallery.items[0].thumb.isNotEmpty;
332
+
gallery.items.isNotEmpty && gallery.items[0].thumb.isNotEmpty;
383
333
return GestureDetector(
384
334
onTap: () {
385
335
if (gallery.uri.isNotEmpty) {
···
395
345
},
396
346
child: Container(
397
347
decoration: BoxDecoration(
398
-
color: theme
399
-
.colorScheme
400
-
.surfaceContainerHighest,
348
+
color: theme.colorScheme.surfaceContainerHighest,
401
349
),
402
350
clipBehavior: Clip.antiAlias,
403
351
child: hasPhoto
404
-
? AppImage(
405
-
url: gallery.items[0].thumb,
406
-
fit: BoxFit.cover,
407
-
)
352
+
? AppImage(url: gallery.items[0].thumb, fit: BoxFit.cover)
408
353
: Center(
409
354
child: Text(
410
355
gallery.title,
411
356
style: TextStyle(
412
357
fontSize: 12,
413
-
color: theme
414
-
.colorScheme
415
-
.onSurfaceVariant,
358
+
color: theme.colorScheme.onSurfaceVariant,
416
359
),
417
360
textAlign: TextAlign.center,
418
361
),
···
450
393
fontSize: 14, // Set to 14
451
394
);
452
395
final styleLabel = TextStyle(
453
-
color: theme.brightness == Brightness.dark
454
-
? Colors.grey[400]
455
-
: Colors.grey[700],
396
+
color: theme.brightness == Brightness.dark ? Colors.grey[400] : Colors.grey[700],
456
397
fontSize: 14, // Set to 14
457
398
);
458
399
return Row(
+2
-6
lib/screens/splash_page.dart
+2
-6
lib/screens/splash_page.dart
···
13
13
}
14
14
15
15
class _SplashPageState extends State<SplashPage> {
16
-
final TextEditingController _handleController = TextEditingController(
17
-
text: '',
18
-
);
16
+
final TextEditingController _handleController = TextEditingController(text: '');
19
17
bool _signingIn = false;
20
18
21
19
Future<void> _signInWithBluesky(BuildContext context) async {
···
75
73
width: double.infinity,
76
74
child: AppButton(
77
75
label: 'Login',
78
-
onPressed: _signingIn
79
-
? null
80
-
: () => _signInWithBluesky(context),
76
+
onPressed: _signingIn ? null : () => _signInWithBluesky(context),
81
77
loading: _signingIn,
82
78
variant: AppButtonVariant.primary,
83
79
height: 44,
+2
-3
lib/widgets/app_image.dart
+2
-3
lib/widgets/app_image.dart
···
1
-
import 'package:flutter/material.dart';
2
1
import 'package:cached_network_image/cached_network_image.dart';
2
+
import 'package:flutter/material.dart';
3
3
4
4
class AppImage extends StatelessWidget {
5
5
final String? url;
···
67
67
);
68
68
if (borderRadius != null) {
69
69
return ClipRRect(
70
-
borderRadius:
71
-
borderRadius!, // BorderRadius is a subclass of BorderRadiusGeometry
70
+
borderRadius: borderRadius!, // BorderRadius is a subclass of BorderRadiusGeometry
72
71
child: image,
73
72
);
74
73
}
+3
-12
lib/widgets/gallery_photo_view.dart
+3
-12
lib/widgets/gallery_photo_view.dart
···
60
60
placeholder: Container(
61
61
color: Colors.black,
62
62
child: const Center(
63
-
child: CircularProgressIndicator(
64
-
strokeWidth: 2,
65
-
color: Colors.white,
66
-
),
63
+
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
67
64
),
68
65
),
69
66
errorWidget: Container(
70
67
color: Colors.black,
71
-
child: const Icon(
72
-
Icons.broken_image,
73
-
color: Colors.grey,
74
-
),
68
+
child: const Icon(Icons.broken_image, color: Colors.grey),
75
69
),
76
70
),
77
71
),
···
81
75
Container(
82
76
width: double.infinity,
83
77
color: Colors.black.withOpacity(0.7),
84
-
padding: const EdgeInsets.symmetric(
85
-
horizontal: 20,
86
-
vertical: 16,
87
-
),
78
+
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
88
79
child: Text(
89
80
photo.alt,
90
81
style: const TextStyle(color: Colors.white, fontSize: 16),
+1
-3
lib/widgets/gallery_preview.dart
+1
-3
lib/widgets/gallery_preview.dart
···
13
13
final Color bgColor = theme.brightness == Brightness.dark
14
14
? Colors.grey[900]!
15
15
: Colors.grey[100]!;
16
-
final photos = gallery.items
17
-
.where((item) => item.thumb.isNotEmpty)
18
-
.toList();
16
+
final photos = gallery.items.where((item) => item.thumb.isNotEmpty).toList();
19
17
return AspectRatio(
20
18
aspectRatio: 3 / 2,
21
19
child: Row(
+3
-10
lib/widgets/justified_gallery_view.dart
+3
-10
lib/widgets/justified_gallery_view.dart
···
33
33
children: [
34
34
for (int i = 0; i < row.length; i++)
35
35
Padding(
36
-
padding: EdgeInsets.only(
37
-
right: i == row.length - 1 ? 0 : spacing,
38
-
),
36
+
padding: EdgeInsets.only(right: i == row.length - 1 ? 0 : spacing),
39
37
child: GestureDetector(
40
-
onTap: onImageTap != null
41
-
? () => onImageTap!(row[i].originalIndex)
42
-
: null,
38
+
onTap: onImageTap != null ? () => onImageTap!(row[i].originalIndex) : null,
43
39
child: SizedBox(
44
40
width: row[i].displayWidth,
45
41
height: row[i].displayHeight,
···
54
50
),
55
51
);
56
52
}
57
-
return Column(
58
-
crossAxisAlignment: CrossAxisAlignment.stretch,
59
-
children: rowWidgets,
60
-
);
53
+
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rowWidgets);
61
54
}
62
55
}
63
56
+3
-10
lib/widgets/plain_text_field.dart
+3
-10
lib/widgets/plain_text_field.dart
···
47
47
duration: const Duration(milliseconds: 150),
48
48
decoration: BoxDecoration(
49
49
border: Border.all(
50
-
color: isFocused
51
-
? theme.colorScheme.primary
52
-
: theme.dividerColor,
50
+
color: isFocused ? theme.colorScheme.primary : theme.dividerColor,
53
51
width: isFocused ? 2 : 1,
54
52
),
55
53
borderRadius: BorderRadius.circular(8),
···
63
61
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 15),
64
62
decoration: InputDecoration(
65
63
hintText: hintText,
66
-
hintStyle: theme.textTheme.bodyMedium?.copyWith(
67
-
color: theme.hintColor,
68
-
),
64
+
hintStyle: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
69
65
border: InputBorder.none,
70
-
contentPadding: const EdgeInsets.symmetric(
71
-
horizontal: 12,
72
-
vertical: 12,
73
-
),
66
+
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
74
67
isDense: true,
75
68
),
76
69
),
+15
-32
lib/widgets/timeline_item.dart
+15
-32
lib/widgets/timeline_item.dart
···
1
1
import 'package:flutter/material.dart';
2
-
import 'package:grain/models/gallery.dart';
3
2
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
4
-
import 'package:grain/widgets/gallery_preview.dart';
5
-
import '../screens/gallery_page.dart';
6
-
import '../screens/comments_page.dart';
7
-
import '../screens/profile_page.dart';
8
3
import 'package:grain/api.dart';
4
+
import 'package:grain/app_theme.dart';
5
+
import 'package:grain/models/gallery.dart';
9
6
import 'package:grain/utils.dart';
10
7
import 'package:grain/widgets/app_image.dart';
11
-
import 'package:grain/app_theme.dart';
8
+
import 'package:grain/widgets/gallery_preview.dart';
9
+
10
+
import '../screens/comments_page.dart';
11
+
import '../screens/gallery_page.dart';
12
+
import '../screens/profile_page.dart';
12
13
13
14
class TimelineItemWidget extends StatelessWidget {
14
15
final Gallery gallery;
15
16
final VoidCallback? onProfileTap;
16
-
const TimelineItemWidget({
17
-
super.key,
18
-
required this.gallery,
19
-
this.onProfileTap,
20
-
});
17
+
const TimelineItemWidget({super.key, required this.gallery, this.onProfileTap});
21
18
22
19
@override
23
20
Widget build(BuildContext context) {
···
38
35
if (actor != null) {
39
36
Navigator.of(context).push(
40
37
MaterialPageRoute(
41
-
builder: (context) =>
42
-
ProfilePage(did: actor.did, showAppBar: true),
38
+
builder: (context) => ProfilePage(did: actor.did, showAppBar: true),
43
39
),
44
40
);
45
41
}
···
112
108
if (gallery.uri.isNotEmpty) {
113
109
Navigator.of(context).push(
114
110
MaterialPageRoute(
115
-
builder: (context) => GalleryPage(
116
-
uri: gallery.uri,
117
-
currentUserDid: apiService.currentUser?.did,
118
-
),
111
+
builder: (context) =>
112
+
GalleryPage(uri: gallery.uri, currentUserDid: apiService.currentUser?.did),
119
113
),
120
114
);
121
115
}
···
129
123
padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
130
124
child: Text(
131
125
gallery.title,
132
-
style: theme.textTheme.titleMedium?.copyWith(
133
-
fontWeight: FontWeight.w600,
134
-
),
126
+
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
135
127
),
136
128
),
137
129
if (gallery.description.isNotEmpty)
···
147
139
),
148
140
const SizedBox(height: 8),
149
141
Padding(
150
-
padding: const EdgeInsets.only(
151
-
top: 12,
152
-
bottom: 12,
153
-
left: 12,
154
-
right: 12,
155
-
),
142
+
padding: const EdgeInsets.only(top: 12, bottom: 12, left: 12, right: 12),
156
143
child: Row(
157
144
children: [
158
145
GestureDetector(
···
163
150
gallery.viewer != null && gallery.viewer!['fav'] != null
164
151
? FontAwesomeIcons.solidHeart
165
152
: FontAwesomeIcons.heart,
166
-
color:
167
-
gallery.viewer != null && gallery.viewer!['fav'] != null
153
+
color: gallery.viewer != null && gallery.viewer!['fav'] != null
168
154
? AppTheme.favoriteColor
169
155
: theme.colorScheme.onSurfaceVariant,
170
156
),
···
185
171
GestureDetector(
186
172
onTap: () {
187
173
Navigator.of(context).push(
188
-
MaterialPageRoute(
189
-
builder: (context) =>
190
-
CommentsPage(galleryUri: gallery.uri),
191
-
),
174
+
MaterialPageRoute(builder: (context) => CommentsPage(galleryUri: gallery.uri)),
192
175
);
193
176
},
194
177
child: Padding(