feat: add vscode settings

+6
.vscode/settings.json
··· 1 + { 2 + "editor.codeActionsOnSave": { 3 + "source.organizeImports": "always" 4 + }, 5 + "dart.lineLength": 100 6 + }
+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
··· 48 48 } 49 49 50 50 // Globally available logger 51 - final appLogger = Logger( 52 - printer: SimpleLogPrinter('Grain'), 53 - output: DualLogOutput(), 54 - ); 51 + final appLogger = Logger(printer: SimpleLogPrinter('Grain'), output: DualLogOutput());
+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
··· 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
··· 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
··· 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
··· 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
··· 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

This is a binary file and will not be displayed.

+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 ),
+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
··· 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
··· 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
··· 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
··· 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
··· 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 -8
lib/widgets/app_button.dart
··· 46 46 elevation: 0, 47 47 shape: RoundedRectangleBorder( 48 48 borderRadius: BorderRadius.circular(borderRadius), 49 - side: isPrimary 50 - ? BorderSide.none 51 - : BorderSide(color: secondaryBorder, width: 1), 49 + side: isPrimary ? BorderSide.none : BorderSide(color: secondaryBorder, width: 1), 52 50 ), 53 51 padding: padding ?? const EdgeInsets.symmetric(horizontal: 16), 54 52 textStyle: theme.textTheme.labelLarge?.copyWith( ··· 70 68 mainAxisAlignment: MainAxisAlignment.center, 71 69 children: [ 72 70 if (icon != null) ...[ 73 - Icon( 74 - icon, 75 - size: 20, 76 - color: isPrimary ? primaryText : secondaryText, 77 - ), 71 + Icon(icon, size: 20, color: isPrimary ? primaryText : secondaryText), 78 72 const SizedBox(width: 8), 79 73 ], 80 74 Text(
+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 }
+4 -13
lib/widgets/bottom_nav_bar.dart
··· 26 26 return Container( 27 27 decoration: BoxDecoration( 28 28 color: Theme.of(context).scaffoldBackgroundColor, 29 - border: Border( 30 - top: BorderSide(color: Theme.of(context).dividerColor, width: 1), 31 - ), 29 + border: Border(top: BorderSide(color: Theme.of(context).dividerColor, width: 1)), 32 30 ), 33 31 height: 42 + MediaQuery.of(context).padding.bottom, 34 32 child: Row( ··· 114 112 decoration: navIndex == 3 115 113 ? BoxDecoration( 116 114 shape: BoxShape.circle, 117 - border: Border.all( 118 - color: AppTheme.primaryColor, 119 - width: 2.2, 120 - ), 115 + border: Border.all(color: AppTheme.primaryColor, width: 2.2), 121 116 ) 122 117 : null, 123 118 child: ClipOval( ··· 130 125 ), 131 126 ) 132 127 : FaIcon( 133 - navIndex == 3 134 - ? FontAwesomeIcons.solidUser 135 - : FontAwesomeIcons.user, 128 + navIndex == 3 ? FontAwesomeIcons.solidUser : FontAwesomeIcons.user, 136 129 size: 16, 137 130 color: navIndex == 3 138 131 ? AppTheme.primaryColor 139 - : Theme.of( 140 - context, 141 - ).colorScheme.onSurfaceVariant, 132 + : Theme.of(context).colorScheme.onSurfaceVariant, 142 133 ), 143 134 ), 144 135 ),
+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
··· 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/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
··· 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(