+28
-3
lib/api.dart
+28
-3
lib/api.dart
···
2
2
import 'package:grain/main.dart';
3
3
import 'package:http/http.dart' as http;
4
4
import 'dart:convert';
5
-
import 'profile.dart';
6
-
import 'gallery.dart';
7
-
import 'notification.dart' as grain;
5
+
import 'models/profile.dart';
6
+
import 'models/gallery.dart';
7
+
import 'models/notification.dart' as grain;
8
8
9
9
class ApiService {
10
10
String? _accessToken;
···
175
175
'Failed to load notifications: status ${response.statusCode}, body: ${response.body}',
176
176
);
177
177
throw Exception('Failed to load notifications: \\${response.statusCode}');
178
+
}
179
+
}
180
+
181
+
Future<List<Profile>> searchActors(String query) async {
182
+
if (_accessToken == null) return [];
183
+
appLogger.i('Searching actors for query: $query with token: $_accessToken');
184
+
185
+
final response = await http.get(
186
+
Uri.parse('$_apiUrl/xrpc/social.grain.actor.searchActors?q=$query'),
187
+
headers: {'Authorization': 'Bearer $_accessToken'},
188
+
);
189
+
if (response.statusCode == 200) {
190
+
final data = json.decode(response.body);
191
+
final items = data['actors'] as List<dynamic>?;
192
+
if (items != null) {
193
+
appLogger.i('Found ${items.length} actors for query: $query');
194
+
return items.map((item) => Profile.fromJson(item)).toList();
195
+
} else {
196
+
return [];
197
+
}
198
+
} else {
199
+
appLogger.e(
200
+
'Failed to search actors: status ${response.statusCode}, body: ${response.body}',
201
+
);
202
+
throw Exception('Failed to search actors: ${response.statusCode}');
178
203
}
179
204
}
180
205
}
lib/app_version_text.dart
lib/widgets/app_version_text.dart
lib/app_version_text.dart
lib/widgets/app_version_text.dart
lib/comment.dart
lib/models/comment.dart
lib/comment.dart
lib/models/comment.dart
+31
-22
lib/comments_page.dart
lib/screens/comments_page.dart
+31
-22
lib/comments_page.dart
lib/screens/comments_page.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:grain/api.dart';
3
-
import 'package:grain/comment.dart';
4
-
import 'package:grain/gallery.dart';
3
+
import 'package:grain/models/comment.dart';
4
+
import 'package:grain/models/gallery.dart';
5
5
import 'package:grain/utils.dart';
6
6
7
7
class CommentsPage extends StatefulWidget {
···
49
49
@override
50
50
Widget build(BuildContext context) {
51
51
return Scaffold(
52
-
appBar: AppBar(title: const Text('Comments')),
52
+
appBar: AppBar(
53
+
title: const Text(
54
+
'Comments',
55
+
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
56
+
),
57
+
),
53
58
body: _loading
54
59
? const Center(child: CircularProgressIndicator())
55
60
: _error
56
61
? const Center(child: Text('Failed to load comments.'))
57
62
: ListView(
58
-
padding: const EdgeInsets.all(12),
59
-
children: [
60
-
if (_gallery != null)
61
-
Text(
62
-
_gallery!.title,
63
-
style: const TextStyle(
64
-
fontWeight: FontWeight.bold,
65
-
fontSize: 18,
66
-
),
63
+
padding: const EdgeInsets.all(12),
64
+
children: [
65
+
if (_gallery != null)
66
+
Text(
67
+
_gallery!.title,
68
+
style: const TextStyle(
69
+
fontWeight: FontWeight.bold,
70
+
fontSize: 18,
71
+
),
72
+
),
73
+
const SizedBox(height: 12),
74
+
_CommentsList(comments: _comments),
75
+
],
67
76
),
68
-
const SizedBox(height: 12),
69
-
_CommentsList(comments: _comments),
70
-
],
71
-
),
72
77
);
73
78
}
74
79
}
···
91
96
return comments.where((c) => c.replyTo == null).toList();
92
97
}
93
98
94
-
Widget _buildCommentTree(Comment comment, Map<String, List<Comment>> repliesByParent, int depth) {
99
+
Widget _buildCommentTree(
100
+
Comment comment,
101
+
Map<String, List<Comment>> repliesByParent,
102
+
int depth,
103
+
) {
95
104
return Padding(
96
105
padding: EdgeInsets.only(left: depth * 18.0),
97
106
child: Column(
···
99
108
children: [
100
109
_CommentTile(comment: comment),
101
110
if (repliesByParent[comment.uri] != null)
102
-
...repliesByParent[comment.uri]!
103
-
.map((reply) => _buildCommentTree(reply, repliesByParent, depth + 1))
104
-
,
111
+
...repliesByParent[comment.uri]!.map(
112
+
(reply) => _buildCommentTree(reply, repliesByParent, depth + 1),
113
+
),
105
114
],
106
115
),
107
116
);
···
161
170
),
162
171
child: AspectRatio(
163
172
aspectRatio:
164
-
(comment.focus!.width > 0 &&
165
-
comment.focus!.height > 0)
173
+
(comment.focus!.width > 0 &&
174
+
comment.focus!.height > 0)
166
175
? comment.focus!.width / comment.focus!.height
167
176
: 1.0,
168
177
child: ClipRRect(
lib/gallery.dart
lib/models/gallery.dart
lib/gallery.dart
lib/models/gallery.dart
+8
-5
lib/gallery_page.dart
lib/screens/gallery_page.dart
+8
-5
lib/gallery_page.dart
lib/screens/gallery_page.dart
···
1
1
import 'package:flutter/material.dart';
2
-
import 'package:grain/gallery.dart';
2
+
import 'package:grain/models/gallery.dart';
3
3
import 'package:grain/api.dart';
4
-
import 'package:grain/justified_gallery_view.dart';
5
-
import 'package:grain/comments_page.dart';
6
-
import 'utils.dart';
4
+
import 'package:grain/widgets/justified_gallery_view.dart';
5
+
import 'package:grain/utils.dart';
6
+
import './comments_page.dart';
7
7
8
8
class GalleryPage extends StatefulWidget {
9
9
final String uri;
···
62
62
.toList();
63
63
return Scaffold(
64
64
appBar: AppBar(
65
-
title: Text(gallery.title.isNotEmpty ? gallery.title : 'Gallery'),
65
+
title: Text(
66
+
gallery.title.isNotEmpty ? gallery.title : 'Gallery',
67
+
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
68
+
),
66
69
),
67
70
body: Padding(
68
71
padding: const EdgeInsets.symmetric(horizontal: 16),
+171
-42
lib/home_page.dart
lib/screens/home_page.dart
+171
-42
lib/home_page.dart
lib/screens/home_page.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:grain/api.dart';
3
-
import 'package:grain/gallery.dart';
4
-
import 'package:grain/gallery_page.dart';
5
-
import 'package:grain/comments_page.dart';
3
+
import 'package:grain/models/gallery.dart';
4
+
import 'gallery_page.dart';
5
+
import 'comments_page.dart';
6
6
import 'profile_page.dart';
7
-
import 'utils.dart';
7
+
import 'package:grain/utils.dart';
8
8
import 'log_page.dart';
9
-
import 'app_version_text.dart';
9
+
import 'package:grain/widgets/app_version_text.dart';
10
10
import 'notifications_page.dart';
11
+
import 'explore_page.dart';
12
+
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
11
13
12
14
class TimelineItem {
13
15
final Gallery gallery;
···
35
37
class _MyHomePageState extends State<MyHomePage> {
36
38
bool showProfile = false;
37
39
bool showNotifications = false;
40
+
bool showExplore = false;
38
41
List<TimelineItem> _timeline = [];
39
42
bool _timelineLoading = true;
40
43
···
63
66
}
64
67
65
68
int get _navIndex {
66
-
if (showProfile) return 2;
67
-
if (showNotifications) return 1;
69
+
if (showProfile) return 3;
70
+
if (showNotifications) return 2;
71
+
if (showExplore) return 1;
68
72
return 0;
69
73
}
70
74
···
262
266
padding: EdgeInsets.zero,
263
267
children: [
264
268
DrawerHeader(
265
-
decoration: BoxDecoration(
266
-
color: Theme.of(context).colorScheme.inversePrimary,
267
-
),
268
-
child: Text(
269
-
'Menu',
270
-
style: TextStyle(color: Colors.white, fontSize: 24),
269
+
decoration: BoxDecoration(color: Colors.white),
270
+
child: Column(
271
+
crossAxisAlignment: CrossAxisAlignment.start,
272
+
mainAxisAlignment: MainAxisAlignment.center,
273
+
children: [
274
+
CircleAvatar(
275
+
radius: 22, // Smaller avatar
276
+
backgroundImage:
277
+
apiService.currentUser?.avatar != null &&
278
+
apiService.currentUser!.avatar.isNotEmpty
279
+
? NetworkImage(apiService.currentUser!.avatar)
280
+
: null,
281
+
backgroundColor: Colors.white,
282
+
child:
283
+
(apiService.currentUser == null ||
284
+
apiService.currentUser!.avatar.isEmpty)
285
+
? const Icon(
286
+
Icons.account_circle,
287
+
size: 32,
288
+
color: Colors.grey,
289
+
)
290
+
: null,
291
+
),
292
+
const SizedBox(height: 6),
293
+
Text(
294
+
apiService.currentUser?.displayName ?? '',
295
+
style: const TextStyle(
296
+
color: Colors.black,
297
+
fontSize: 15, // Smaller text
298
+
fontWeight: FontWeight.bold,
299
+
),
300
+
),
301
+
if (apiService.currentUser?.handle != null)
302
+
Text(
303
+
'@${apiService.currentUser!.handle}',
304
+
style: const TextStyle(
305
+
color: Colors.black54,
306
+
fontSize: 11, // Smaller text
307
+
),
308
+
),
309
+
const SizedBox(height: 6),
310
+
Row(
311
+
mainAxisAlignment: MainAxisAlignment.start,
312
+
children: [
313
+
Text(
314
+
(apiService.currentUser?.followersCount ?? 0)
315
+
.toString(),
316
+
style: const TextStyle(
317
+
color: Colors.black,
318
+
fontWeight: FontWeight.bold,
319
+
fontSize: 13,
320
+
),
321
+
),
322
+
const SizedBox(width: 4),
323
+
const Text(
324
+
'Followers',
325
+
style: TextStyle(color: Colors.black54, fontSize: 10),
326
+
),
327
+
const SizedBox(width: 16),
328
+
Text(
329
+
(apiService.currentUser?.followsCount ?? 0).toString(),
330
+
style: const TextStyle(
331
+
color: Colors.black,
332
+
fontWeight: FontWeight.bold,
333
+
fontSize: 13,
334
+
),
335
+
),
336
+
const SizedBox(width: 4),
337
+
const Text(
338
+
'Following',
339
+
style: TextStyle(color: Colors.black54, fontSize: 10),
340
+
),
341
+
],
342
+
),
343
+
],
271
344
),
272
345
),
273
346
ListTile(
274
-
leading: const Icon(Icons.home),
347
+
leading: const Icon(FontAwesomeIcons.house),
275
348
title: const Text('Home'),
276
349
onTap: () {
277
350
Navigator.pop(context);
278
351
setState(() {
279
352
showProfile = false;
280
353
showNotifications = false;
354
+
showExplore = false;
281
355
});
282
356
},
283
357
),
284
358
ListTile(
285
-
leading: const Icon(Icons.person),
359
+
leading: const Icon(FontAwesomeIcons.magnifyingGlass),
360
+
title: const Text('Explore'),
361
+
onTap: () {
362
+
Navigator.pop(context);
363
+
setState(() {
364
+
showExplore = true;
365
+
showProfile = false;
366
+
showNotifications = false;
367
+
});
368
+
},
369
+
),
370
+
ListTile(
371
+
leading: const Icon(FontAwesomeIcons.user),
286
372
title: const Text('Profile'),
287
373
onTap: () {
288
374
Navigator.pop(context);
289
375
setState(() {
290
376
showProfile = true;
291
377
showNotifications = false;
378
+
showExplore = false;
292
379
});
293
380
},
294
381
),
295
382
ListTile(
296
-
leading: const Icon(Icons.list_alt),
383
+
leading: const Icon(FontAwesomeIcons.list),
297
384
title: const Text('Logs'),
298
385
onTap: () {
299
386
Navigator.pop(context);
···
322
409
),
323
410
),
324
411
title: Text(
325
-
showNotifications ? 'Notifications' : widget.title,
412
+
showNotifications
413
+
? 'Notifications'
414
+
: showExplore
415
+
? 'Explore'
416
+
: widget.title,
326
417
style: const TextStyle(
327
418
fontSize: 18,
328
419
fontWeight: FontWeight.w600,
···
338
429
),
339
430
body: Stack(
340
431
children: [
341
-
if (!showProfile && !showNotifications) _buildTimeline(),
432
+
if (!showProfile && !showNotifications && !showExplore)
433
+
_buildTimeline(),
434
+
if (showExplore)
435
+
Positioned.fill(
436
+
child: Material(
437
+
color: Theme.of(
438
+
context,
439
+
).scaffoldBackgroundColor.withOpacity(0.98),
440
+
child: SafeArea(child: Stack(children: [ExplorePage()])),
441
+
),
442
+
),
342
443
if (showNotifications)
343
444
Positioned.fill(
344
445
child: Material(
···
371
472
bottomNavigationBar: BottomNavigationBar(
372
473
items: <BottomNavigationBarItem>[
373
474
BottomNavigationBarItem(
374
-
icon: Icon(_navIndex == 0 ? Icons.home : Icons.home_outlined),
475
+
icon: Transform.translate(
476
+
offset: const Offset(0, 10),
477
+
child: FaIcon(FontAwesomeIcons.house),
478
+
),
479
+
label: '',
480
+
),
481
+
BottomNavigationBarItem(
482
+
icon: Transform.translate(
483
+
offset: const Offset(0, 10),
484
+
child: const FaIcon(FontAwesomeIcons.magnifyingGlass),
485
+
),
375
486
label: '',
376
487
),
377
488
BottomNavigationBarItem(
378
-
icon: const Icon(Icons.notifications_none),
489
+
icon: Transform.translate(
490
+
offset: const Offset(0, 10),
491
+
child: const FaIcon(FontAwesomeIcons.solidBell),
492
+
),
379
493
label: '',
380
494
),
381
495
BottomNavigationBarItem(
382
496
icon: apiService.currentUser?.avatar != null
383
-
? Container(
384
-
width: 24,
385
-
height: 24,
386
-
decoration: BoxDecoration(
387
-
shape: BoxShape.circle,
388
-
border: Border.all(
389
-
color: _navIndex == 2
390
-
? Colors.lightBlue
391
-
: Colors.transparent,
392
-
width: 3,
497
+
? Transform.translate(
498
+
offset: const Offset(0, 10),
499
+
child: Container(
500
+
width: 32,
501
+
height: 32,
502
+
decoration: BoxDecoration(
503
+
shape: BoxShape.circle,
504
+
border: Border.all(
505
+
color: _navIndex == 3
506
+
? Colors.lightBlue
507
+
: Colors.transparent,
508
+
width: 3,
509
+
),
393
510
),
394
-
),
395
-
child: CircleAvatar(
396
-
radius: 12,
397
-
backgroundImage: NetworkImage(
398
-
apiService.currentUser!.avatar,
511
+
child: ClipOval(
512
+
child: Image.network(
513
+
apiService.currentUser!.avatar,
514
+
fit: BoxFit.cover,
515
+
width: 24,
516
+
height: 24,
517
+
),
399
518
),
400
-
backgroundColor: Colors.transparent,
401
519
),
402
520
)
403
-
: Icon(
404
-
_navIndex == 2
405
-
? Icons.account_circle
406
-
: Icons.account_circle_outlined,
521
+
: FaIcon(
522
+
_navIndex == 3
523
+
? FontAwesomeIcons.solidUser
524
+
: FontAwesomeIcons.user,
407
525
),
408
526
label: '',
409
527
),
···
411
529
currentIndex: _navIndex,
412
530
selectedItemColor: Colors.lightBlue,
413
531
onTap: (index) {
414
-
if (index == 1) {
532
+
if (index == 2) {
415
533
setState(() {
416
534
showNotifications = true;
417
535
showProfile = false;
536
+
showExplore = false;
418
537
});
419
538
return;
420
539
}
421
-
if (index == 2) {
540
+
if (index == 3) {
422
541
setState(() {
423
542
showProfile = true;
424
543
showNotifications = false;
544
+
showExplore = false;
545
+
});
546
+
return;
547
+
}
548
+
if (index == 1) {
549
+
setState(() {
550
+
showExplore = true;
551
+
showProfile = false;
552
+
showNotifications = false;
425
553
});
426
554
return;
427
555
}
···
429
557
setState(() {
430
558
showProfile = false;
431
559
showNotifications = false;
560
+
showExplore = false;
432
561
});
433
562
return;
434
563
}
+1
-1
lib/justified_gallery_view.dart
lib/widgets/justified_gallery_view.dart
+1
-1
lib/justified_gallery_view.dart
lib/widgets/justified_gallery_view.dart
lib/log_page.dart
lib/screens/log_page.dart
lib/log_page.dart
lib/screens/log_page.dart
+2
-2
lib/main.dart
+2
-2
lib/main.dart
···
4
4
import 'package:flutter_dotenv/flutter_dotenv.dart';
5
5
import 'package:google_fonts/google_fonts.dart';
6
6
import 'package:grain/app_logger.dart';
7
-
import 'package:grain/splash_page.dart';
8
-
import 'package:grain/home_page.dart';
7
+
import 'package:grain/screens/splash_page.dart';
8
+
import 'package:grain/screens/home_page.dart';
9
9
10
10
class AppConfig {
11
11
static late final String apiUrl;
lib/notification.dart
lib/models/notification.dart
lib/notification.dart
lib/models/notification.dart
+3
-3
lib/notifications_page.dart
lib/screens/notifications_page.dart
+3
-3
lib/notifications_page.dart
lib/screens/notifications_page.dart
···
1
1
import 'package:flutter/material.dart';
2
-
import 'api.dart';
3
-
import 'notification.dart' as grain;
4
-
import 'utils.dart';
2
+
import 'package:grain/api.dart';
3
+
import 'package:grain/models/notification.dart' as grain;
4
+
import 'package:grain/utils.dart';
5
5
6
6
class NotificationsPage extends StatefulWidget {
7
7
const NotificationsPage({super.key});
lib/profile.dart
lib/models/profile.dart
lib/profile.dart
lib/models/profile.dart
+2
-2
lib/profile_page.dart
lib/screens/profile_page.dart
+2
-2
lib/profile_page.dart
lib/screens/profile_page.dart
···
1
1
import 'package:flutter/material.dart';
2
-
import 'package:grain/gallery.dart';
2
+
import 'package:grain/models/gallery.dart';
3
3
import 'package:grain/api.dart';
4
-
import 'package:grain/gallery_page.dart';
4
+
import 'gallery_page.dart';
5
5
6
6
class ProfilePage extends StatefulWidget {
7
7
final dynamic profile;
+100
lib/screens/explore_page.dart
+100
lib/screens/explore_page.dart
···
1
+
import 'dart:async';
2
+
import 'package:flutter/material.dart';
3
+
import 'package:grain/api.dart';
4
+
import 'package:grain/models/profile.dart';
5
+
import 'profile_page.dart';
6
+
7
+
class ExplorePage extends StatelessWidget {
8
+
const ExplorePage({super.key});
9
+
10
+
Future<List<Profile>> _delayedSearch(String query) async {
11
+
await Future.delayed(const Duration(milliseconds: 500));
12
+
return apiService.searchActors(query);
13
+
}
14
+
15
+
@override
16
+
Widget build(BuildContext context) {
17
+
return Padding(
18
+
padding: const EdgeInsets.all(16.0),
19
+
child: SearchAnchor.bar(
20
+
barHintText: 'Search for users',
21
+
barShape: WidgetStateProperty.all(
22
+
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
23
+
),
24
+
barElevation: WidgetStateProperty.all(0),
25
+
suggestionsBuilder: (context, controller) {
26
+
if (controller.text.isEmpty) {
27
+
return [];
28
+
}
29
+
return [
30
+
FutureBuilder<List<Profile>>(
31
+
future: _delayedSearch(controller.text),
32
+
builder: (context, snapshot) {
33
+
if (snapshot.connectionState == ConnectionState.waiting) {
34
+
return const ListTile(
35
+
title: Text('Searching...'),
36
+
leading: SizedBox(
37
+
width: 20,
38
+
height: 20,
39
+
child: CircularProgressIndicator(strokeWidth: 2),
40
+
),
41
+
);
42
+
}
43
+
if (snapshot.hasError) {
44
+
return const ListTile(title: Text('Error searching users'));
45
+
}
46
+
final results = snapshot.data ?? [];
47
+
if (results.isEmpty) {
48
+
return const ListTile(title: Text('No users found'));
49
+
}
50
+
return Column(
51
+
mainAxisSize: MainAxisSize.min,
52
+
children: results.map((profile) {
53
+
return ListTile(
54
+
leading: profile.avatar.isNotEmpty
55
+
? CircleAvatar(
56
+
backgroundImage: NetworkImage(profile.avatar),
57
+
radius: 16,
58
+
)
59
+
: const CircleAvatar(
60
+
radius: 16,
61
+
child: Icon(
62
+
Icons.account_circle,
63
+
color: Colors.grey,
64
+
),
65
+
),
66
+
title: Text(
67
+
profile.displayName.isNotEmpty
68
+
? profile.displayName
69
+
: '@${profile.handle}',
70
+
),
71
+
subtitle: profile.handle.isNotEmpty
72
+
? Text('@${profile.handle}')
73
+
: null,
74
+
onTap: () async {
75
+
// Navigate to the profile page for the selected user
76
+
final loadedProfile = await apiService.fetchProfile(
77
+
did: profile.did,
78
+
);
79
+
if (context.mounted) {
80
+
Navigator.of(context).push(
81
+
MaterialPageRoute(
82
+
builder: (context) => ProfilePage(
83
+
profile: loadedProfile,
84
+
showAppBar: true,
85
+
),
86
+
),
87
+
);
88
+
}
89
+
},
90
+
);
91
+
}).toList(),
92
+
);
93
+
},
94
+
),
95
+
];
96
+
},
97
+
),
98
+
);
99
+
}
100
+
}
lib/splash_page.dart
lib/screens/splash_page.dart
lib/splash_page.dart
lib/screens/splash_page.dart
+8
pubspec.lock
+8
pubspec.lock
···
211
211
description: flutter
212
212
source: sdk
213
213
version: "0.0.0"
214
+
font_awesome_flutter:
215
+
dependency: "direct main"
216
+
description:
217
+
name: font_awesome_flutter
218
+
sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a
219
+
url: "https://pub.dev"
220
+
source: hosted
221
+
version: "10.8.0"
214
222
freezed_annotation:
215
223
dependency: transitive
216
224
description: