this repo has no description

feat: add vscode settings

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