[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Tweaks (#71)

Co-authored-by: Jean Carlo Polo <vaniapolo@gmail.com>

authored by

Davi Rodrigues
Jean Carlo Polo
and committed by
GitHub
2e949159 d2d68935

+1760 -1990
+3 -1
.github/workflows/android-internal-release.yml
··· 25 25 fetch-depth: 0 26 26 27 27 - name: Setup JDK 17 28 - uses: actions/setup-java@v1 28 + uses: actions/setup-java@v4 29 29 with: 30 + distribution: "temurin" 31 + cache: "gradle" 30 32 java-version: 17 31 33 32 34 - name: Setup Flutter
+28 -1
analysis_options.yaml
··· 1 - include: package:flutter_lints/flutter.yaml 1 + include: 2 + - package:very_good_analysis/analysis_options.yaml 3 + - package:flutter_lints/flutter.yaml 2 4 3 5 formatter: 4 6 page_width: 130 5 7 analyzer: 6 8 errors: 9 + avoid_catches_without_on_clauses: ignore 10 + avoid_dynamic_calls: ignore 11 + cascade_invocations: ignore 12 + inference_failure_on_function_invocation: ignore 13 + inference_failure_on_instance_creation: ignore 7 14 invalid_annotation_target: ignore 15 + lines_longer_than_80_chars: ignore 16 + comment_references: ignore # enable when starting documentation 17 + public_member_api_docs: ignore # enable when starting documentation 18 + sort_pub_dependencies: ignore 19 + prefer_null_aware_method_calls: ignore 20 + strict_raw_type: ignore 21 + avoid_equals_and_hash_code_on_mutable_classes: ignore 22 + inference_failure_on_function_return_type: ignore 23 + inference_failure_on_untyped_parameter: ignore 24 + flutter_style_todos: ignore 25 + specify_nonobvious_property_types: ignore 26 + unnecessary_null_checks: ignore 27 + sort_constructors_first: ignore 28 + unawaited_futures: ignore 29 + avoid_positional_boolean_parameters: ignore 30 + use_setters_to_change_properties: ignore 31 + prefer_asserts_with_message: ignore 32 + document_ignores: ignore 33 + one_member_abstracts: ignore 34 + avoid_bool_literals_in_conditional_expressions: ignore
+2 -1
android/gradle.properties
··· 1 1 org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError 2 2 android.useAndroidX=true 3 3 android.enableJetifier=true 4 - kotlin.jvm.target.validation.mode=IGNORE 4 + kotlin.jvm.target.validation.mode=IGNORE 5 + org.gradle.caching=true
+10 -18
lib/main.dart
··· 3 3 import 'package:flutter/services.dart'; 4 4 import 'package:flutter_dotenv/flutter_dotenv.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; 6 - import 'package:flutter_native_splash/flutter_native_splash.dart'; 7 6 import 'package:fvp/fvp.dart' as fvp; 8 7 9 - import 'src/core/di/service_locator.dart'; 10 - import 'src/core/theme/data/models/app_theme.dart'; 11 - import 'src/core/utils/logging/logging.dart'; 12 - import 'src/core/utils/logging/riverpod_logger.dart'; 13 - import 'src/sprk_app.dart'; 8 + import 'package:sparksocial/src/core/di/service_locator.dart'; 9 + import 'package:sparksocial/src/core/theme/data/models/app_theme.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/logging.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/riverpod_logger.dart'; 12 + import 'package:sparksocial/src/sprk_app.dart'; 14 13 15 14 // Global RouteObserver instance 16 15 final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>(); 17 16 18 17 void main() async { 19 - // Preserve the native splash screen 20 - WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); 21 - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); 22 - 23 - await dotenv.load(fileName: ".env"); 24 - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); 18 + WidgetsFlutterBinding.ensureInitialized(); 25 19 26 - // Initialize IMGLY Video Editor SDK 27 - // Note: You need to add a license file to assets folder and reference it in pubspec.yaml 28 - // VESDK.unlockWithLicense("assets/licenses/vesdk_license"); 20 + await dotenv.load(); 21 + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); 29 22 30 23 // Force dark status bar and navigation bar 31 24 SystemChrome.setSystemUIOverlayStyle(AppTheme.darkSystemUiStyle); 32 25 33 26 fvp.registerWith(); 34 27 35 - // Initialize dependencies for new architecture 36 - await configureDependencies(); 28 + await initServiceLocator(); 37 29 38 30 // Setup logging for production/debug 39 31 _setupLogging(); 40 32 41 33 // Create a ProviderContainer with the Riverpod logger 42 34 final container = riverpod.ProviderContainer(observers: [SparkRiverpodLogger()]); 43 - runApp(riverpod.UncontrolledProviderScope(container: container, child: SprkApp())); 35 + runApp(riverpod.UncontrolledProviderScope(container: container, child: const SprkApp())); 44 36 } 45 37 46 38 /// Setup logging framework based on environment
+2 -3
lib/src/core/auth/data/models/login_result.dart
··· 6 6 /// Result of a login attempt 7 7 @freezed 8 8 class LoginResult with _$LoginResult { 9 - const LoginResult._(); 10 9 const factory LoginResult({ 11 10 required LoginStatus status, 12 11 String? error, 13 12 }) = _LoginResult; 13 + const LoginResult._(); 14 14 15 15 factory LoginResult.success() => const LoginResult(status: LoginStatus.success); 16 16 factory LoginResult.failed(String error) => LoginResult(status: LoginStatus.failed, error: error); 17 17 factory LoginResult.codeRequired(String error) => LoginResult(status: LoginStatus.codeRequired, error: error); 18 - 19 18 20 19 bool get isSuccess => status == LoginStatus.success; 21 20 bool get isCodeRequired => status == LoginStatus.codeRequired; 22 - } 21 + }
+30 -29
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 1 - import 'dart:convert'; 2 1 import 'dart:async'; 2 + import 'dart:convert'; 3 3 4 4 import 'package:atproto/atproto.dart'; 5 5 import 'package:atproto/core.dart'; 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:http/http.dart' as http; 8 + 8 9 import 'package:sparksocial/src/core/auth/data/models/login_result.dart'; 9 10 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 10 11 import 'package:sparksocial/src/core/config/app_config.dart'; 11 12 import 'package:sparksocial/src/core/storage/storage.dart'; 12 13 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 14 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 13 15 14 16 /// Implementation of the authentication repository for AT Protocol 15 17 class AuthRepositoryImpl implements AuthRepository { 18 + AuthRepositoryImpl() { 19 + _logger.i('Initializing AuthRepository'); 20 + _initialize(); 21 + } 16 22 Session? _session; 17 23 ATProto? _atProto; 18 24 String? _dmAccessToken; 19 25 String? _dmRefreshToken; 20 26 21 - final _logger = GetIt.instance<LogService>().getLogger('AuthRepository'); 27 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('AuthRepository'); 22 28 23 29 final Completer<void> _initCompleter = Completer<void>(); 24 30 Future<void> get initializationComplete => _initCompleter.future; ··· 37 43 38 44 @override 39 45 String? get dmRefreshToken => _dmRefreshToken; 40 - 41 - AuthRepositoryImpl() { 42 - _logger.i('Initializing AuthRepository'); 43 - _initialize(); 44 - } 45 46 46 47 Future<void> _initialize() async { 47 48 try { ··· 63 64 return null; 64 65 } 65 66 66 - final pdsService = services.firstWhere((s) => s['id'] == '#atproto_pds', orElse: () => {}); 67 + final pdsService = services.firstWhere((s) => s['id'] == '#atproto_pds'); 67 68 68 - final String? pdsUrl = pdsService['serviceEndpoint'] as String?; 69 + final pdsUrl = pdsService['serviceEndpoint'] as String?; 69 70 if (pdsUrl == null) { 70 71 return null; 71 72 } ··· 91 92 92 93 if (response.statusCode == 200) { 93 94 final data = jsonDecode(response.body); 94 - _dmAccessToken = data['accessJwt']; 95 - _dmRefreshToken = data['refreshJwt']; 95 + _dmAccessToken = data['accessJwt'] as String?; 96 + _dmRefreshToken = data['refreshJwt'] as String?; 96 97 await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, _dmAccessToken!); 97 98 await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, _dmRefreshToken!); 98 99 _logger.i('Logged in to message service successfully'); ··· 115 116 return; 116 117 } 117 118 118 - _session = Session.fromJson(json.decode(savedSessionJson)); 119 + _session = Session.fromJson(json.decode(savedSessionJson) as Map<String, dynamic>); 119 120 if (_session == null) { 120 121 _logger.w('Failed to parse saved session'); 121 122 return; ··· 147 148 148 149 if (response.statusCode == 200) { 149 150 final data = jsonDecode(response.body); 150 - _dmAccessToken = data['access_token']; 151 - _dmRefreshToken = data['refresh_token']; 151 + _dmAccessToken = data['access_token'] as String?; 152 + _dmRefreshToken = data['refresh_token'] as String?; 152 153 } else { 153 154 if (!await refreshDMToken()) { 154 155 throw Exception('Failed to refresh DM token'); ··· 178 179 179 180 if (response.statusCode == 200) { 180 181 final data = jsonDecode(response.body); 181 - await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, data['access_token']); 182 - _dmAccessToken = data['access_token']; 183 - _dmRefreshToken = data['refresh_token']; 184 - await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, data['refresh_token']); 182 + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, data['access_token'] as String? ?? ''); 183 + _dmAccessToken = data['access_token'] as String?; 184 + _dmRefreshToken = data['refresh_token'] as String?; 185 + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, data['refresh_token'] as String? ?? ''); 185 186 return true; 186 187 } 187 188 _logger.e('Failed to refresh DM token: ${response.statusCode} - ${response.body}'); ··· 196 197 } 197 198 198 199 _logger.i('Refreshing session for user: ${_session!.handle}'); 199 - String? service = _session!.didDoc != null ? _extractPdsDomain(_session!.didDoc!) : null; 200 + var service = _session!.didDoc != null ? _extractPdsDomain(_session!.didDoc!) : null; 200 201 201 202 if (service == null) { 202 203 _logger.d('Fetching DID document from PLC directory'); 203 204 final didDocResponse = await http.get(Uri.parse('https://plc.directory/${_session!.did}')); 204 205 if (didDocResponse.statusCode == 200) { 205 - service = _extractPdsDomain(json.decode(didDocResponse.body)); 206 + service = _extractPdsDomain(json.decode(didDocResponse.body) as Map<String, dynamic>); 206 207 } 207 208 } 208 209 ··· 263 264 Future<LoginResult> login(String handle, String password, {String? authCode}) async { 264 265 try { 265 266 _logger.i('Login attempt for user: $handle'); 266 - ATProto at = ATProto.anonymous(service: 'pds.sprk.so'); 267 + final at = ATProto.anonymous(service: 'pds.sprk.so'); 267 268 _logger.d('Resolving handle: $handle'); 268 269 final didRes = await at.identity.resolveHandle(handle: handle); 269 - String did = didRes.data.did; 270 - _logger.d('Resolved DID: $did'); 271 - 272 - _logger.d('Fetching DID document'); 270 + final did = didRes.data.did; 271 + _logger 272 + ..d('Resolved DID: $did') 273 + ..d('Fetching DID document'); 273 274 final didDocResponse = await http.get(Uri.parse('https://plc.directory/$did')); 274 275 275 276 if (didDocResponse.statusCode != 200) { ··· 279 280 280 281 final didDoc = json.decode(didDocResponse.body); 281 282 282 - String? pdsUrl = 283 - (didDoc['service'] as List<dynamic>).firstWhere((s) => s['id'] == '#atproto_pds', orElse: () => {})['serviceEndpoint'] 283 + final pdsUrl = 284 + (didDoc['service'] as List<dynamic>).firstWhere((s) => s['id'] == '#atproto_pds', orElse: () => '')['serviceEndpoint'] 284 285 as String?; 285 286 286 287 if (pdsUrl == null) { ··· 288 289 throw Exception('PDS endpoint not found in DID document'); 289 290 } 290 291 291 - String pdsDomain = Uri.parse(pdsUrl).host; 292 + final pdsDomain = Uri.parse(pdsUrl).host; 292 293 _logger.d('Using PDS domain: $pdsDomain'); 293 294 294 295 try { ··· 332 333 Future<({bool success, String? error})> register(String handle, String email, String password, String? inviteCode) async { 333 334 try { 334 335 _logger.i('Registration attempt for handle: $handle, email: $email'); 335 - ATProto at = ATProto.anonymous(service: 'pds.sprk.so'); 336 + final at = ATProto.anonymous(service: 'pds.sprk.so'); 336 337 _logger.d('Creating account'); 337 338 final createResponse = await at.server.createAccount( 338 339 handle: handle,
+17 -15
lib/src/core/auth/data/repositories/identity_repository_impl.dart
··· 8 8 import 'package:sparksocial/src/core/auth/data/repositories/identity_repository.dart'; 9 9 import 'package:sparksocial/src/core/storage/storage.dart'; 10 10 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 12 12 13 /// Implementation of [IdentityRepository] with caching capabilities 13 14 class IdentityRepositoryImpl implements IdentityRepository { 15 + /// Creates a new [IdentityRepositoryImpl] instance and loads the cache 16 + IdentityRepositoryImpl(this._storageManager) { 17 + _loadCache(); 18 + } 14 19 final Map<String, String> _didToHandleCache = {}; 15 20 final Map<String, String> _handleToDidCache = {}; 16 21 final Map<String, Map<String, dynamic>> _didDocCache = {}; ··· 18 23 static const Duration _cacheExpiration = Duration(hours: 2); 19 24 20 25 final StorageManager _storageManager; 21 - final _logger = GetIt.instance<LogService>().getLogger('IdentityRepository'); 22 - 23 - /// Creates a new [IdentityRepositoryImpl] instance and loads the cache 24 - IdentityRepositoryImpl(this._storageManager) { 25 - _loadCache(); 26 - } 26 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('IdentityRepository'); 27 27 28 28 Future<void> _loadCache() async { 29 29 try { ··· 55 55 } 56 56 57 57 void _loadDidToHandleCache(String jsonData) { 58 - final Map<String, dynamic> data = json.decode(jsonData); 58 + final data = json.decode(jsonData) as Map<String, dynamic>; 59 59 _didToHandleCache.clear(); 60 60 61 61 data.forEach((key, value) { ··· 66 66 } 67 67 68 68 void _loadHandleToDidCache(String jsonData) { 69 - final Map<String, dynamic> data = json.decode(jsonData); 69 + final data = json.decode(jsonData) as Map<String, dynamic>; 70 70 _handleToDidCache.clear(); 71 71 72 72 data.forEach((key, value) { ··· 77 77 } 78 78 79 79 void _loadDidDocCache(String jsonData) { 80 - final Map<String, dynamic> data = json.decode(jsonData); 80 + final data = json.decode(jsonData) as Map<String, dynamic>; 81 81 _didDocCache.clear(); 82 82 83 83 data.forEach((key, value) { ··· 147 147 return null; 148 148 } 149 149 150 - for (var aka in alsoKnownAs) { 150 + for (final aka in alsoKnownAs) { 151 151 if (aka is String && aka.startsWith('at://')) { 152 152 final handle = aka.replaceFirst('at://', ''); 153 153 ··· 187 187 } 188 188 189 189 final didDoc = json.decode(response.body); 190 - _didDocCache[did] = didDoc; 190 + _didDocCache[did] = didDoc as Map<String, dynamic>; 191 191 await _saveCache(); 192 192 193 193 return didDoc; ··· 195 195 196 196 @override 197 197 Future<Map<String, dynamic>?> resolveHandleToDidDoc(String handle) async { 198 - String? did = _handleToDidCache[handle]; 198 + var did = _handleToDidCache[handle]; 199 199 200 200 if (did == null) { 201 201 did = await resolveHandleToDid(handle); ··· 204 204 } 205 205 } 206 206 207 - return await resolveDidToDidDoc(did); 207 + return resolveDidToDidDoc(did); 208 208 } 209 209 210 210 @override ··· 237 237 @override 238 238 Future<Map<String, String?>> resolveDidsToHandles(List<String> dids) async { 239 239 final results = <String, String?>{}; 240 - final futures = <Future>[]; 240 + final futures = <Future<String?>>[]; 241 241 242 242 for (final did in dids) { 243 243 if (_didToHandleCache.containsKey(did)) { ··· 248 248 futures.add( 249 249 resolveDidToHandle(did).then((handle) { 250 250 results[did] = handle; 251 + return null; 251 252 }), 252 253 ); 253 254 } ··· 262 263 @override 263 264 Future<Map<String, String?>> resolveHandlesToDids(List<String> handles) async { 264 265 final results = <String, String?>{}; 265 - final futures = <Future>[]; 266 + final futures = <Future<String?>>[]; 266 267 267 268 for (final handle in handles) { 268 269 if (_handleToDidCache.containsKey(handle)) { ··· 273 274 futures.add( 274 275 resolveHandleToDid(handle).then((did) { 275 276 results[handle] = did; 277 + return null; 276 278 }), 277 279 ); 278 280 }
+10 -11
lib/src/core/auth/data/repositories/onboarding_repository_impl.dart
··· 4 4 import 'package:atproto/core.dart'; 5 5 import 'package:bluesky/bluesky.dart' as bs; 6 6 import 'package:get_it/get_it.dart'; 7 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 8 + import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; 7 9 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 8 10 import 'package:sparksocial/src/core/network/atproto/data/models/graph_models.dart'; 9 - 10 11 import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository.dart'; 11 12 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 12 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 13 - import 'onboarding_repository.dart'; 13 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 14 14 15 15 class OnboardingRepositoryImpl implements OnboardingRepository { 16 - final RepoRepository _repoRepository; 17 - final AuthRepository _authRepository; 18 - final _logger = GetIt.instance<LogService>().getLogger('OnboardingRepository'); 19 - 20 16 OnboardingRepositoryImpl({required RepoRepository repoRepository, required AuthRepository authRepository}) 21 17 : _repoRepository = repoRepository, 22 18 _authRepository = authRepository; 19 + final RepoRepository _repoRepository; 20 + final AuthRepository _authRepository; 21 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('OnboardingRepository'); 23 22 24 23 Session? get _session => _authRepository.session; 25 24 ATProto? get _atproto => _authRepository.atproto; ··· 87 86 } 88 87 89 88 final record = <String, dynamic>{ 90 - '\$type': 'so.sprk.actor.profile', 89 + r'$type': 'so.sprk.actor.profile', 91 90 'displayName': displayName, 92 91 'description': description, 93 92 if (avatarField != null) 'avatar': avatarField, ··· 112 111 113 112 // Convert raw data to our structured model 114 113 final rawData = response.data.toJson(); 115 - final List<dynamic> rawFollows = rawData['follows'] as List<dynamic>; 114 + final rawFollows = rawData['follows'] as List<dynamic>; 116 115 117 116 final follows = rawFollows 118 117 .map( 119 - (followData) => ProfileView.fromJson(followData), 118 + (followData) => ProfileView.fromJson(followData as Map<String, dynamic>), 120 119 ) 121 120 .toList(); 122 121 ··· 126 125 @override 127 126 Future<void> createSparkFollow(String subject) async { 128 127 final record = <String, dynamic>{ 129 - '\$type': 'so.sprk.graph.follow', 128 + r'$type': 'so.sprk.graph.follow', 130 129 'subject': subject, 131 130 'createdAt': DateTime.now().toUtc().toIso8601String(), 132 131 };
+1 -1
lib/src/core/config/app_config.dart
··· 23 23 static int get apiTimeoutSeconds => _getIntValue('API_TIMEOUT_SECONDS', 30); 24 24 25 25 /// Maximum upload file size in MB. 26 - static double get maxUploadSizeMB => _getDoubleValue('MAX_UPLOAD_SIZE_MB', 100.0); 26 + static double get maxUploadSizeMB => _getDoubleValue('MAX_UPLOAD_SIZE_MB', 100); 27 27 28 28 /// The current application environment (development, production, etc.) 29 29 static String get environment => _getStringValue('ENVIRONMENT', 'development');
+10 -10
lib/src/core/di/service_locator.dart
··· 1 1 import 'package:get_it/get_it.dart'; 2 2 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository_impl.dart'; 3 + import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; 4 + import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 + import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 7 + import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 8 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository_impl.dart'; 3 9 import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; 4 10 import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository_impl.dart'; 5 11 import 'package:sparksocial/src/core/storage/cache/cache_manager_impl.dart'; 6 12 import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; 7 13 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 8 - import 'package:sparksocial/src/core/theme/data/repositories/theme_repository.dart'; 9 - import 'package:sparksocial/src/core/theme/data/repositories/theme_repository_impl.dart'; 10 14 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 11 - import 'package:sparksocial/src/features/auth/auth.dart'; 15 + import 'package:sparksocial/src/core/storage/preferences/settings_repository_impl.dart'; 12 16 import 'package:sparksocial/src/core/storage/storage.dart'; 13 - import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 17 + import 'package:sparksocial/src/core/theme/data/repositories/theme_repository.dart'; 18 + import 'package:sparksocial/src/core/theme/data/repositories/theme_repository_impl.dart'; 14 19 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 15 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository_impl.dart'; 16 - import 'package:sparksocial/src/core/storage/preferences/settings_repository_impl.dart'; 17 - import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; 18 - import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; 19 - import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 20 - import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 20 + import 'package:sparksocial/src/features/auth/auth.dart'; 21 21 22 22 // This is the ONLY PLACE IN THE ENTIRE APP where implementations are imported 23 23 // All the other files should import interfaces only (polymorphism) to keep everything decoupled
+2 -2
lib/src/core/feed_algorithms/feed_following.dart
··· 1 - import 'package:bluesky/bluesky.dart' as bsky; 2 1 import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/bluesky.dart' as bsky; 3 3 import 'package:get_it/get_it.dart'; 4 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 5 4 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 6 6 7 7 Future<FeedSkeleton> followingSkeletonFunction({int? limit, String? cursor}) async { 8 8 limit ??= 10;
+1 -1
lib/src/core/feed_algorithms/feed_for_you.dart
··· 1 - import 'package:bluesky/bluesky.dart' as bsky; 2 1 import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/bluesky.dart' as bsky; 3 3 import 'package:get_it/get_it.dart'; 4 4 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 5 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart';
+2 -2
lib/src/core/feed_algorithms/feed_latest_sprk.dart
··· 1 1 import 'dart:convert'; 2 2 3 3 import 'package:atproto_core/atproto_core.dart'; 4 - import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 4 import 'package:get_it/get_it.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 6 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 7 7 8 8 Future<FeedSkeleton> latestSprkSkeletonFunction({int? limit, String? cursor}) async { ··· 21 21 }, // need to call the API directly because latest-sprk is not a parsable AtUri 22 22 service: 'feeds.sprk.so', 23 23 to: (jsonMap) => jsonMap, 24 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 24 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 25 25 ); 26 26 final feedData = feedGenRes.data['feed'] as List<dynamic>?; 27 27 final uris = feedData?.map((item) => item['post'] as String).toList() ?? [];
+3 -1
lib/src/core/feed_algorithms/feed_mutuals.dart
··· 2 2 3 3 Future<FeedSkeleton> mutualsSkeletonFunction({int? limit, String? cursor}) async { 4 4 limit ??= 10; 5 - return FeedSkeleton(feed: [],); // TODO: implement 5 + return const FeedSkeleton( 6 + feed: [], 7 + ); // TODO: implement 6 8 }
+1 -1
lib/src/core/feed_algorithms/feed_shared.dart
··· 3 3 4 4 Future<FeedSkeleton> sharedSkeletonFunction({int? limit, String? cursor}) async { 5 5 limit ??= 10; 6 - return FeedSkeleton(feed: []); // TODO: implement 6 + return const FeedSkeleton(feed: []); // TODO: implement 7 7 } 8 8 9 9 Future<Map<AtUri, HardcodedFeedExtraInfoShared>> sharedExtraInfoFunction(List<AtUri> uris) async {
+6 -3
lib/src/core/feed_algorithms/hardcoded_feed_algorithm.dart
··· 1 1 import 'package:atproto/core.dart'; 2 - import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 3 2 import 'package:sparksocial/src/core/feed_algorithms/feed_following.dart'; 4 3 import 'package:sparksocial/src/core/feed_algorithms/feed_for_you.dart'; 5 - import 'package:sparksocial/src/core/feed_algorithms/feed_mutuals.dart'; 6 4 import 'package:sparksocial/src/core/feed_algorithms/feed_latest_sprk.dart'; 5 + import 'package:sparksocial/src/core/feed_algorithms/feed_mutuals.dart'; 7 6 import 'package:sparksocial/src/core/feed_algorithms/feed_shared.dart'; 7 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 8 8 9 9 typedef SkeletonFunction = Future<FeedSkeleton> Function({int? limit, String? cursor}); 10 10 typedef ExtraInfoFunction = Future<Map<AtUri, HardcodedFeedExtraInfo>> Function(List<AtUri> uris); ··· 36 36 switch (feed) { 37 37 case HardCodedFeedEnum.shared: 38 38 return sharedExtraInfo; 39 - default: 39 + case HardCodedFeedEnum.following: 40 + case HardCodedFeedEnum.mutuals: 41 + case HardCodedFeedEnum.forYou: 42 + case HardCodedFeedEnum.latestSprk: 40 43 return null; 41 44 } 42 45 }
+2 -4
lib/src/core/network/atproto/atproto.dart
··· 1 - library; 2 - 3 1 export 'data/models/models.dart'; 4 - export 'data/repositories/sprk_repository.dart'; 5 2 export 'data/repositories/actor_repository.dart'; 6 - export 'data/repositories/repo_repository.dart'; 7 3 export 'data/repositories/feed_repository.dart'; 8 4 export 'data/repositories/graph_repository.dart'; 5 + export 'data/repositories/repo_repository.dart'; 6 + export 'data/repositories/sprk_repository.dart';
+11 -11
lib/src/core/network/atproto/data/models/actor_models.dart
··· 7 7 8 8 @freezed 9 9 class ActorViewer with _$ActorViewer { 10 - const ActorViewer._(); 11 10 @JsonSerializable(explicitToJson: true) 12 11 const factory ActorViewer({ 13 12 bool? muted, ··· 19 18 @AtUriConverter() AtUri? followedBy, 20 19 KnownFollowers? followers, 21 20 }) = _ActorViewer; 21 + const ActorViewer._(); 22 22 23 23 factory ActorViewer.fromJson(Map<String, dynamic> json) => _$ActorViewerFromJson(json); 24 24 } 25 25 26 26 @freezed 27 27 class KnownFollowers with _$KnownFollowers { 28 - const KnownFollowers._(); 29 28 @JsonSerializable(explicitToJson: true) 30 29 const factory KnownFollowers({ 31 30 required int count, 32 31 required List<String> followersDids, // to avoid circular dependency 33 32 }) = _KnownFollowers; 33 + const KnownFollowers._(); 34 34 35 35 factory KnownFollowers.fromJson(Map<String, dynamic> json) => switch (json) { 36 36 {'followers': final List<ProfileViewBasic> profiles, 'count': final int count} => _$KnownFollowersFromJson({ ··· 43 43 44 44 @freezed 45 45 class ProfileViewBasic with _$ProfileViewBasic { 46 - const ProfileViewBasic._(); 47 46 @JsonSerializable(explicitToJson: true) 48 47 const factory ProfileViewBasic({ 49 48 required String did, ··· 54 53 ActorViewer? viewer, 55 54 List<StrongRef>? stories, 56 55 }) = _ProfileViewBasic; 56 + const ProfileViewBasic._(); 57 57 58 58 factory ProfileViewBasic.fromJson(Map<String, dynamic> json) => _$ProfileViewBasicFromJson(json); 59 59 } 60 60 61 61 @freezed 62 62 class ProfileView with _$ProfileView { 63 - const ProfileView._(); 64 63 @JsonSerializable(explicitToJson: true) 65 64 const factory ProfileView({ 66 65 required String did, ··· 74 73 List<Label>? labels, 75 74 // no stories here for some reason 76 75 }) = _ProfileView; 76 + const ProfileView._(); 77 77 78 78 factory ProfileView.fromJson(Map<String, dynamic> json) => _$ProfileViewFromJson(json); 79 79 } ··· 82 82 class SearchActorsResponse { 83 83 SearchActorsResponse({required this.actors, this.cursor}); 84 84 85 - /// List of returned actor profiles. 86 - final List<ProfileView> actors; 87 - 88 - /// Cursor indicating the next page of results, or null when no more pages. 89 - final String? cursor; 90 - 91 85 /// Create a [SearchActorsResponse] from JSON. 92 86 factory SearchActorsResponse.fromJson(Map<String, dynamic> json) { 93 87 final actorsJson = json['actors'] as List<dynamic>? ?? <dynamic>[]; ··· 97 91 ); 98 92 } 99 93 94 + /// List of returned actor profiles. 95 + final List<ProfileView> actors; 96 + 97 + /// Cursor indicating the next page of results, or null when no more pages. 98 + final String? cursor; 99 + 100 100 /// Convert the object back to JSON. 101 101 Map<String, dynamic> toJson() => {'actors': actors.map((e) => e.toJson()).toList(), if (cursor != null) 'cursor': cursor}; 102 102 } 103 103 104 104 @freezed 105 105 class ProfileViewDetailed with _$ProfileViewDetailed { 106 - const ProfileViewDetailed._(); 107 106 @JsonSerializable(explicitToJson: true) 108 107 const factory ProfileViewDetailed({ 109 108 required String did, ··· 123 122 StrongRef? pinnedPost, // this is a list if the backend implements https://github.com/sprksocial/spark-back-end/issues/13 124 123 List<StrongRef>? stories, 125 124 }) = _ProfileViewDetailed; 125 + const ProfileViewDetailed._(); 126 126 127 127 factory ProfileViewDetailed.fromJson(Map<String, dynamic> json) => _$ProfileViewDetailedFromJson(json); 128 128 }
+31 -33
lib/src/core/network/atproto/data/models/feed_models.dart
··· 67 67 /// HardCoded feeds are "fake" and completely generated in the frontend 68 68 @freezed 69 69 class Feed with _$Feed { 70 + factory Feed.fromJson(Map<String, dynamic> json) => _$FeedFromJson(json); 70 71 const Feed._(); 71 72 @JsonSerializable(explicitToJson: true) 72 73 const factory Feed.custom({required String name, @AtUriConverter() required AtUri uri}) = FeedCustom; ··· 81 82 82 83 String get identifier => 83 84 when(custom: (name, uri) => uri.toString(), hardCoded: (hardCodedFeed) => 'hardcoded:${hardCodedFeed.name}'); 84 - 85 - factory Feed.fromJson(Map<String, dynamic> json) => _$FeedFromJson(json); 86 85 } 87 86 88 87 /// Skeleton of a FeedView. Needs to be hydrated. ··· 111 110 /// GetTimeline returns a FeedViewPost array 112 111 @freezed 113 112 class FeedViewPost with _$FeedViewPost { 114 - const FeedViewPost._(); 115 113 @JsonSerializable(explicitToJson: true) 116 114 const factory FeedViewPost({ 117 115 required PostView post, ··· 119 117 // "reason": { "type": "union", "refs": ["#reasonRepost", "#reasonPin"] } i think we don't have to use this value for now 120 118 // there's also a String feedContext "Context provided by feed generator that may be passed back alongside interactions." 121 119 }) = _FeedViewPostPost; 120 + const FeedViewPost._(); 122 121 123 122 factory FeedViewPost.fromJson(Map<String, dynamic> json) => _$FeedViewPostFromJson(json); 124 123 } 125 124 126 125 @freezed 127 126 class ReplyRef with _$ReplyRef { 128 - const ReplyRef._(); 129 127 @JsonSerializable(explicitToJson: true) 130 128 const factory ReplyRef({ 131 129 required ReplyRefPostReference root, // post, not found or blocked 132 130 required ReplyRefPostReference parent, // post, not found or blocked 133 131 ProfileViewBasic? grandparentAuthor, 134 132 }) = _ReplyRef; 133 + const ReplyRef._(); 135 134 136 135 factory ReplyRef.fromJson(Map<String, dynamic> json) => _$ReplyRefFromJson(json); 137 136 } ··· 166 165 167 166 @freezed 168 167 class BlockedAuthor with _$BlockedAuthor { 169 - const BlockedAuthor._(); 170 168 @JsonSerializable(explicitToJson: true) 171 169 const factory BlockedAuthor({required String did, Viewer? viewer}) = _BlockedAuthor; 170 + const BlockedAuthor._(); 172 171 173 172 factory BlockedAuthor.fromJson(Map<String, dynamic> json) => _$BlockedAuthorFromJson(json); 174 173 } 175 174 176 175 @freezed 177 176 class PostThread with _$PostThread { 178 - const PostThread._(); 179 177 @JsonSerializable(explicitToJson: true) 180 178 const factory PostThread({required PostView post, List<PostView>? parent, List<PostView>? replies}) = _PostThread; 179 + const PostThread._(); 181 180 182 181 factory PostThread.fromJson(Map<String, dynamic> json) => _$PostThreadFromJson(json); 183 182 } 184 183 185 184 @freezed 186 185 class Viewer with _$Viewer { 187 - const Viewer._(); 188 186 @JsonSerializable(explicitToJson: true) 189 187 const factory Viewer({ 190 188 @AtUriConverter() AtUri? repost, ··· 195 193 bool? embeddingDisabled, 196 194 bool? pinned, 197 195 }) = _Viewer; 196 + const Viewer._(); 198 197 199 198 factory Viewer.fromJson(Map<String, dynamic> json) => _$ViewerFromJson(json); 200 199 } 201 200 202 201 @freezed 203 202 class PostView with _$PostView { 204 - const PostView._(); 205 203 @JsonSerializable(explicitToJson: true) 206 204 const factory PostView({ 207 205 @AtUriConverter() required AtUri uri, 208 206 required String cid, 209 207 required ProfileViewBasic author, 210 208 required PostRecord record, 211 - @Default(false) bool isRepost, 212 209 required DateTime indexedAt, 210 + @Default(false) bool isRepost, 213 211 int? likeCount, 214 212 int? replyCount, 215 213 int? repostCount, ··· 219 217 //SoundView? sound, 220 218 EmbedView? embed, // aturi 221 219 }) = _PostView; 220 + const PostView._(); 222 221 223 222 factory PostView.fromJson(Map<String, dynamic> json) => _$PostViewFromJson(json); 224 223 ··· 423 422 424 423 @freezed 425 424 class EmbedViewExternal with _$EmbedViewExternal { 426 - const EmbedViewExternal._(); 427 - 428 425 @JsonSerializable(explicitToJson: true) 429 426 const factory EmbedViewExternal({ 430 427 required String uri, ··· 432 429 @Default('') String description, 433 430 @AtUriConverter() AtUri? thumb, 434 431 }) = _EmbedViewExternal; 432 + const EmbedViewExternal._(); 435 433 436 434 factory EmbedViewExternal.fromJson(Map<String, dynamic> json) => _$EmbedViewExternalFromJson(json); 437 435 } 438 436 439 437 @freezed 440 438 class FeedSkeleton with _$FeedSkeleton { 441 - const FeedSkeleton._(); 442 439 @JsonSerializable(explicitToJson: true) 443 440 const factory FeedSkeleton({required List<SkeletonFeedPost> feed, String? cursor}) = _FeedSkeleton; 441 + const FeedSkeleton._(); 444 442 445 443 factory FeedSkeleton.fromJson(Map<String, dynamic> json) => _$FeedSkeletonFromJson(json); 446 444 } 447 445 448 446 @freezed 449 447 class ImageUploadResult with _$ImageUploadResult { 450 - const ImageUploadResult._(); 451 448 @JsonSerializable(explicitToJson: true) 452 449 const factory ImageUploadResult({required String fullsize, required String alt, required Map<String, dynamic> image}) = 453 450 _ImageUploadResult; 451 + const ImageUploadResult._(); 454 452 455 453 factory ImageUploadResult.fromJson(Map<String, dynamic> json) => _$ImageUploadResultFromJson(json); 456 454 } ··· 458 456 /// Represents the index range for a facet in the text 459 457 @freezed 460 458 class FacetIndex with _$FacetIndex { 461 - const FacetIndex._(); 462 - 463 459 @JsonSerializable(explicitToJson: true) 464 460 const factory FacetIndex({ 465 461 /// Start index (inclusive) ··· 468 464 /// End index (exclusive) 469 465 required int byteEnd, 470 466 }) = _FacetIndex; 467 + const FacetIndex._(); 471 468 472 469 /// Create a FacetIndex from JSON 473 470 factory FacetIndex.fromJson(Map<String, dynamic> json) => _$FacetIndexFromJson(json); ··· 517 514 /// Represents a richtext facet for text formatting, mentions, links, etc. 518 515 @freezed 519 516 class Facet with _$Facet { 520 - const Facet._(); 521 - 522 517 @JsonSerializable(explicitToJson: true) 523 518 const factory Facet({ 524 519 /// Index range for the facet in the text ··· 527 522 /// Features represented by this facet (mention, link, hashtag, etc.) 528 523 required List<FacetFeature> features, 529 524 }) = _Facet; 525 + const Facet._(); 530 526 531 527 /// Create a Facet from JSON 532 528 factory Facet.fromJson(Map<String, dynamic> json) => _$FacetFromJson(json); ··· 534 530 535 531 @freezed 536 532 class ViewImage with _$ViewImage { 537 - const ViewImage._(); 538 - 539 533 @JsonSerializable(explicitToJson: true) 540 534 const factory ViewImage({ 541 535 @AtUriConverter() required AtUri thumb, ··· 543 537 String? alt, 544 538 // aspectRatio: {width: int, height: int} 545 539 }) = _ViewImage; 540 + const ViewImage._(); 546 541 547 542 factory ViewImage.fromJson(Map<String, dynamic> json) => _$ViewImageFromJson(json); 548 543 } ··· 574 569 switch (thread) { 575 570 case bsky.UPostThreadViewRecord(:final data): 576 571 try { 577 - bsky.EmbedView? embed = data.post.embed; 572 + var embed = data.post.embed; 578 573 if (data.post.embed is bsky.UEmbedViewExternal) { 579 574 embed = null; 580 575 } ··· 645 640 // Check nested embeds array in the record value 646 641 if (recordJson['embeds'] != null && recordJson['embeds'] is List) { 647 642 final embedsList = recordJson['embeds'] as List; 648 - bool shouldRemoveEmbed = false; 643 + var shouldRemoveEmbed = false; 649 644 650 - for (var nestedEmbed in embedsList) { 645 + for (final nestedEmbed in embedsList) { 651 646 if (nestedEmbed is Map<String, dynamic>) { 652 647 // Check external embeds in the nested embeds 653 648 if (nestedEmbed[r'$type'] == 'app.bsky.embed.external#view' && nestedEmbed['cid'] == null) { ··· 671 666 final recordEmbedJson = embedJson['record'] as Map<String, dynamic>; 672 667 if (recordEmbedJson['record'] != null) { 673 668 final recordJson = recordEmbedJson['record'] as Map<String, dynamic>; 674 - 669 + 675 670 // Check if it's a viewRecord and has required fields 676 671 if (recordJson[r'$type'] == 'app.bsky.embed.record#viewRecord') { 677 - if (recordJson['uri'] == null || recordJson['cid'] == null || 678 - recordJson['author'] == null || recordJson['value'] == null || 672 + if (recordJson['uri'] == null || 673 + recordJson['cid'] == null || 674 + recordJson['author'] == null || 675 + recordJson['value'] == null || 679 676 recordJson['indexedAt'] == null) { 680 677 postViewJson.remove('embed'); 681 678 } ··· 687 684 // Additional safety check - if we have any embed that might contain a record view, validate it 688 685 void validateRecordViewInEmbed(Map<String, dynamic> embedData, String path) { 689 686 if (embedData[r'$type'] == 'app.bsky.embed.record#viewRecord') { 690 - if (embedData['uri'] == null || embedData['cid'] == null || 691 - embedData['author'] == null || embedData['value'] == null || 687 + if (embedData['uri'] == null || 688 + embedData['cid'] == null || 689 + embedData['author'] == null || 690 + embedData['value'] == null || 692 691 embedData['indexedAt'] == null) { 693 692 postViewJson.remove('embed'); 694 693 return; 695 694 } 696 695 } 697 - 696 + 698 697 // Recursively check nested structures 699 698 embedData.forEach((key, value) { 700 699 if (value is Map<String, dynamic>) { 701 700 validateRecordViewInEmbed(value, '$path.$key'); 702 701 } else if (value is List) { 703 - for (int i = 0; i < value.length; i++) { 702 + for (var i = 0; i < value.length; i++) { 704 703 if (value[i] is Map<String, dynamic>) { 705 - validateRecordViewInEmbed(value[i], '$path.$key[$i]'); 704 + validateRecordViewInEmbed(value[i] as Map<String, dynamic>, '$path.$key[$i]'); 706 705 } 707 706 } 708 707 } ··· 738 737 }) 739 738 .whereType<Thread>() 740 739 .toList(), 741 - context: null, 742 740 ); 743 741 return thread; 744 742 } catch (e) { ··· 756 754 757 755 @freezed 758 756 class ThreadContext with _$ThreadContext { 759 - const ThreadContext._(); 760 757 @JsonSerializable(explicitToJson: true) 761 758 const factory ThreadContext({@AtUriConverter() AtUri? rootAuthorLike}) = _ThreadContext; 759 + const ThreadContext._(); 762 760 763 761 factory ThreadContext.fromJson(Map<String, dynamic> json) => _$ThreadContextFromJson(json); 764 762 } 765 763 766 764 @freezed 767 765 class StoryView with _$StoryView { 768 - const StoryView._(); 769 766 @JsonSerializable(explicitToJson: true) 770 767 const factory StoryView({ 771 768 required String cid, ··· 776 773 EmbedView? media, 777 774 // viewer eventually i think 778 775 }) = _StoryView; 776 + const StoryView._(); 779 777 780 778 factory StoryView.fromJson(Map<String, dynamic> json) => _$StoryViewFromJson(json); 781 779 }
+16 -16
lib/src/core/network/atproto/data/models/labeler_models.dart
··· 7 7 part 'labeler_models.g.dart'; 8 8 9 9 const defaultLabels = [ 10 - "!hide", 11 - "!no-promote", 12 - "!warn", 13 - "!no-unauthenticated", 14 - "dmca-violation", 15 - "doxxing", 16 - "porn", 17 - "sexual", 18 - "nudity", 19 - "nsfl", 20 - "gore", 10 + '!hide', 11 + '!no-promote', 12 + '!warn', 13 + '!no-unauthenticated', 14 + 'dmca-violation', 15 + 'doxxing', 16 + 'porn', 17 + 'sexual', 18 + 'nudity', 19 + 'nsfl', 20 + 'gore', 21 21 ]; 22 22 23 23 enum Blurs { ··· 70 70 71 71 @freezed 72 72 abstract class LabelPreference with _$LabelPreference { 73 - const LabelPreference._(); 74 73 @JsonSerializable(explicitToJson: true) 75 74 factory LabelPreference({ 76 75 required String value, ··· 80 79 required Setting setting, 81 80 required bool adultOnly, 82 81 }) = _LabelPreference; 82 + const LabelPreference._(); 83 83 84 84 factory LabelPreference.fromJson(Map<String, dynamic> json) => _$LabelPreferenceFromJson(json); 85 85 } 86 86 87 87 @freezed 88 88 abstract class LabelerView with _$LabelerView { 89 - const LabelerView._(); 90 89 @JsonSerializable(explicitToJson: true) 91 90 factory LabelerView({ 92 91 @AtUriConverter() required AtUri uri, ··· 98 97 LabelerViewerState? labelerViewer, 99 98 List<Label>? labels, 100 99 }) = _LabelerView; 100 + const LabelerView._(); 101 101 102 102 factory LabelerView.fromJson(Map<String, dynamic> json) => _$LabelerViewFromJson(json); 103 103 } 104 104 105 105 @freezed 106 106 abstract class LabelerViewDetailed with _$LabelerViewDetailed { 107 - const LabelerViewDetailed._(); 108 107 @JsonSerializable(explicitToJson: true) 109 108 factory LabelerViewDetailed({ 110 109 @AtUriConverter() required AtUri uri, ··· 117 116 LabelerPolicies? policies, 118 117 List<Label>? labels, 119 118 }) = _LabelerViewDetailed; 119 + const LabelerViewDetailed._(); 120 120 121 121 factory LabelerViewDetailed.fromJson(Map<String, dynamic> json) => _$LabelerViewDetailedFromJson(json); 122 122 } 123 123 124 124 @freezed 125 125 abstract class LabelerViewerState with _$LabelerViewerState { 126 - const LabelerViewerState._(); 127 126 @JsonSerializable(explicitToJson: true) 128 127 factory LabelerViewerState({@AtUriConverter() required AtUri like, @AtUriConverter() required AtUri look}) = 129 128 _LabelerViewerState; 129 + const LabelerViewerState._(); 130 130 131 131 factory LabelerViewerState.fromJson(Map<String, dynamic> json) => _$LabelerViewerStateFromJson(json); 132 132 } 133 133 134 134 @freezed 135 135 abstract class LabelerPolicies with _$LabelerPolicies { 136 - const LabelerPolicies._(); 137 136 @JsonSerializable(explicitToJson: true) 138 137 factory LabelerPolicies({ 139 138 required List<String> 140 139 labelValues, // knownValues (array of strings, optional): a set of suggested or common values for this field. Values are not limited to this set (aka, not a closed enum). 141 140 List<LabelValueDefinition>? labelValueDefinitions, 142 141 }) = _LabelerPolicies; 142 + const LabelerPolicies._(); 143 143 144 144 factory LabelerPolicies.fromJson(Map<String, dynamic> json) => _$LabelerPoliciesFromJson(json); 145 145 }
+1 -1
lib/src/core/network/atproto/data/models/models.dart
··· 2 2 export 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 3 3 export 'package:sparksocial/src/core/network/atproto/data/models/graph_models.dart'; 4 4 export 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 5 - export 'package:sparksocial/src/core/network/atproto/data/models/records.dart'; 5 + export 'package:sparksocial/src/core/network/atproto/data/models/records.dart';
+5 -8
lib/src/core/network/atproto/data/models/records.dart
··· 8 8 9 9 @Freezed(unionKey: r'$type') 10 10 class Record with _$Record { 11 + factory Record.fromJson(Map<String, dynamic> json) => _$RecordFromJson(json); 11 12 const Record._(); 12 13 @JsonSerializable(explicitToJson: true) 13 14 @FreezedUnionValue('so.sprk.feed.post') ··· 27 28 @FreezedUnionValue('so.sprk.feed.story') 28 29 const factory Record.story({ 29 30 required Embed media, 31 + required DateTime createdAt, 30 32 StrongRef? sound, 31 33 List<SelfLabel>? selfLabels, 32 34 List<String>? tags, 33 - required DateTime createdAt, 34 35 }) = StoryRecord; 35 36 36 37 @JsonSerializable(explicitToJson: true) ··· 75 76 final regex = RegExp(r'#(\w+)'); 76 77 return regex.allMatches(text).map((match) => match.group(1)!).toList(); 77 78 } 78 - 79 - factory Record.fromJson(Map<String, dynamic> json) => _$RecordFromJson(json); 80 79 } 81 80 82 81 /// Skeleton of a ReplyRef. Needs to be hydrated. 83 82 @freezed 84 83 class RecordReplyRef with _$RecordReplyRef { 85 - const RecordReplyRef._(); 86 84 @JsonSerializable(explicitToJson: true) 87 85 const factory RecordReplyRef({required StrongRef root, required StrongRef parent}) = _RecordReplyRef; 86 + const RecordReplyRef._(); 88 87 89 88 factory RecordReplyRef.fromJson(Map<String, dynamic> json) => _$RecordReplyRefFromJson(json); 90 89 } ··· 143 142 144 143 @freezed 145 144 class EmbedExternal with _$EmbedExternal { 146 - const EmbedExternal._(); 147 - 148 145 @JsonSerializable(explicitToJson: true) 149 146 const factory EmbedExternal({ 150 147 required String uri, ··· 152 149 @Default('') String description, 153 150 Blob? thumb, 154 151 }) = _EmbedExternal; 152 + const EmbedExternal._(); 155 153 156 154 factory EmbedExternal.fromJson(Map<String, dynamic> json) => _$EmbedExternalFromJson(json); 157 155 } 158 156 159 157 @freezed 160 158 class Image with _$Image { 161 - const Image._(); 162 - 163 159 @JsonSerializable(explicitToJson: true) 164 160 const factory Image({ 165 161 required Blob image, 166 162 String? alt, 167 163 // aspectRatio: {width: int, height: int} 168 164 }) = _Image; 165 + const Image._(); 169 166 170 167 factory Image.fromJson(Map<String, dynamic> json) => _$ImageFromJson(json); 171 168 }
+1 -1
lib/src/core/network/atproto/data/repositories/actor_repository.dart
··· 9 9 Future<ProfileViewDetailed> getProfile(String did); 10 10 11 11 /// Get multiple profiles by their DIDs 12 - /// 12 + /// 13 13 /// [dids] A list of DIDs to fetch profiles for 14 14 Future<List<ProfileViewDetailed>> getProfiles(List<String> dids); 15 15
+14 -14
lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart
··· 6 6 import 'package:http/http.dart' as http; 7 7 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 8 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository.dart'; 9 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 10 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 12 12 13 /// Actor-related API endpoints implementation 13 14 class ActorRepositoryImpl implements ActorRepository { 14 - final SprkRepository _client; 15 - final _logger = GetIt.instance<LogService>().getLogger('ActorAPI'); 16 - 17 15 ActorRepositoryImpl(this._client) { 18 16 _logger.v('ActorAPI initialized'); 19 17 } 18 + final SprkRepository _client; 19 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('ActorAPI'); 20 20 21 21 @override 22 22 Future<ProfileViewDetailed> getProfile(String did) async { ··· 38 38 parameters: {'actor': did}, 39 39 headers: {'atproto-proxy': _client.sprkDid}, 40 40 to: (jsonMap) => jsonMap, 41 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 41 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 42 42 ); 43 43 return ProfileViewDetailed.fromJson(result.data as Map<String, dynamic>); 44 44 } catch (e) { 45 - _logger.e('Failed to retrieve profile for DID: $did', error: e); 46 - _logger.i('Trying to get profile from bluesky'); 45 + _logger..e('Failed to retrieve profile for DID: $did', error: e) 46 + ..i('Trying to get profile from bluesky'); 47 47 final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 48 48 final profile = await bluesky.actor.getProfile(actor: did); 49 49 _logger.d('Profile retrieved successfully from bluesky'); ··· 77 77 parameters: parameters, 78 78 headers: {'atproto-proxy': _client.sprkDid}, 79 79 to: (jsonMap) => jsonMap, 80 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 80 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 81 81 ); 82 82 83 83 _logger.d('Actor search completed successfully'); ··· 118 118 final response = await http.get(Uri.parse('https://spark-match.sparksplatforms.workers.dev/?did=$did')); 119 119 if (response.statusCode == 200) { 120 120 final data = jsonDecode(response.body); 121 - final bool isSupporter = data['found'] == true; 121 + final isSupporter = data['found'] == true; 122 122 _logger.d('Early supporter status for $did: $isSupporter'); 123 123 return isSupporter; 124 124 } ··· 149 149 NSID.parse('so.sprk.actor.getPreferences'), 150 150 headers: {'atproto-proxy': _client.sprkDid}, 151 151 to: (jsonMap) => jsonMap, 152 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 152 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 153 153 ); 154 154 155 155 if (result.status != HttpStatus.ok) { ··· 213 213 parameters: {'actors': dids}, 214 214 headers: {'atproto-proxy': _client.sprkDid}, 215 215 to: (jsonMap) => jsonMap, 216 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 216 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 217 217 ); 218 - return (result.data["profiles"] as List).map((json) => ProfileViewDetailed.fromJson(json)).toList(); 218 + return (result.data['profiles']! as List).map((json) => ProfileViewDetailed.fromJson(json as Map<String, dynamic>)).toList(); 219 219 } catch (e) { 220 - _logger.e('Failed to retrieve profile for DIDs: $dids', error: e); 221 - _logger.i('Trying to get profiles from bluesky'); 220 + _logger..e('Failed to retrieve profile for DIDs: $dids', error: e) 221 + ..i('Trying to get profiles from bluesky'); 222 222 final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 223 223 final profiles = await bluesky.actor.getProfiles(actors: dids); 224 224 _logger.d('Profiles retrieved successfully from bluesky');
+1 -1
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 1 + import 'package:atproto/atproto.dart'; 1 2 import 'package:atproto_core/atproto_core.dart'; 2 3 import 'package:image_picker/image_picker.dart'; 3 - import 'package:atproto/atproto.dart'; 4 4 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 5 5 6 6 /// Interface for Feed-related API endpoints
+65 -67
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 2 2 import 'dart:io'; 3 3 import 'dart:typed_data'; 4 4 5 - import 'package:atproto/core.dart'; 6 5 import 'package:atproto/atproto.dart'; 6 + import 'package:atproto/core.dart'; 7 7 import 'package:bluesky/bluesky.dart' as bsky; 8 + /// embed converter 9 + // ignore: implementation_imports 10 + import 'package:bluesky/src/services/entities/converter/embed_converter.dart'; 8 11 import 'package:get_it/get_it.dart'; 9 12 import 'package:http/http.dart' as http; 10 13 import 'package:image/image.dart' as img; 11 14 import 'package:image_picker/image_picker.dart'; 12 15 import 'package:path/path.dart' as path; 13 16 import 'package:sparksocial/src/core/config/app_config.dart'; 17 + import 'package:sparksocial/src/core/feed_algorithms/hardcoded_feed_algorithm.dart'; 14 18 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 15 19 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 16 - import 'package:sparksocial/src/core/feed_algorithms/hardcoded_feed_algorithm.dart'; 17 20 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 18 21 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 19 - // ignore: implementation_imports 20 - import 'package:bluesky/src/services/entities/converter/embed_converter.dart'; 22 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 21 23 22 24 /// Implementation of Feed-related API endpoints 23 25 class FeedRepositoryImpl implements FeedRepository { 24 - final SprkRepository _client; 25 - final _logger = GetIt.instance<LogService>().getLogger('FeedRepository'); 26 - 27 26 FeedRepositoryImpl(this._client) { 28 27 _logger.v('FeedRepository initialized'); 29 28 } 29 + final SprkRepository _client; 30 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('FeedRepository'); 30 31 31 32 List<T> _parseAndFilterPosts<T>({ 32 33 required List<dynamic> rawPosts, ··· 41 42 if (postData['reply'] != null) { 42 43 continue; 43 44 } 44 - final parsedPost = fromJson(postData); 45 + final parsedPost = fromJson(postData as Map<String, dynamic>); 45 46 final postView = getPostView(parsedPost); 46 47 47 48 if (postView.hasSupportedMedia) { ··· 81 82 NSID.parse('so.sprk.feed.getFeedSkeleton'), 82 83 parameters: {'feed': feed, 'limit': limit, 'cursor': cursor}, 83 84 service: 'feeds.sprk.so', 84 - to: (jsonMap) => FeedSkeleton.fromJson(jsonMap), 85 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 85 + to: FeedSkeleton.fromJson, 86 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 86 87 ); 87 88 _logger.d('Feed skeleton retrieved successfully'); 88 89 return result.data; ··· 126 127 parameters: {'uris': uris}, 127 128 headers: {'atproto-proxy': _client.sprkDid}, 128 129 to: (jsonMap) { 129 - final posts = jsonMap['posts'] as List<dynamic>; 130 + final posts = jsonMap['posts']! as List<dynamic>; 130 131 return _parseAndFilterPosts<PostView>( 131 132 rawPosts: posts, 132 133 fromJson: PostView.fromJson, ··· 134 135 source: 'sprk', 135 136 ); 136 137 }, 137 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 138 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 138 139 ); 139 140 _logger.d('Posts retrieved successfully'); 140 141 ··· 196 197 parameters: parameters, 197 198 headers: {'atproto-proxy': _client.sprkDid}, 198 199 to: (jsonMap) { 199 - final rawFeed = jsonMap['feed'] as List<dynamic>; 200 + final rawFeed = jsonMap['feed']! as List<dynamic>; 200 201 final feedPosts = _parseAndFilterPosts<FeedViewPost>( 201 202 rawPosts: rawFeed, 202 203 fromJson: FeedViewPost.fromJson, ··· 205 206 ); 206 207 return (posts: feedPosts, cursor: jsonMap['cursor'] as String?); 207 208 }, 208 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 209 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 209 210 ); 210 211 _logger.d('Author feed retrieved successfully from Sprk'); 211 212 return result.data; ··· 270 271 271 272 final likeRecord = { 272 273 // eventually use a like record class here for consistency 273 - "\$type": "so.sprk.feed.like", 274 - "subject": {"cid": postCid, "uri": postUri.toString()}, 275 - "createdAt": DateTime.now().toUtc().toIso8601String(), 274 + r'$type': 'so.sprk.feed.like', 275 + 'subject': {'cid': postCid, 'uri': postUri.toString()}, 276 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 276 277 }; 277 278 278 279 final result = await atproto.repo.createRecord(collection: NSID.parse('so.sprk.feed.like'), record: likeRecord); ··· 332 333 333 334 // Upload images if provided 334 335 Map<String, dynamic>? embedJson; 335 - if (imageFiles case List<XFile> files when files.isNotEmpty) { 336 + if (imageFiles case final List<XFile> files when files.isNotEmpty) { 336 337 _logger.d('Uploading ${files.length} images for comment'); 337 - final List<Image> uploadedImageMaps = await uploadImages(imageFiles: files, altTexts: altTexts); 338 + final uploadedImageMaps = await uploadImages(imageFiles: files, altTexts: altTexts); 338 339 embedJson = EmbedImage(images: uploadedImageMaps).toJson(); 339 340 } 340 341 341 342 // Create the correct record JSON depending on the target platform. 342 - final bool isSprk = parentUri.toString().contains('sprk'); 343 + final isSprk = parentUri.toString().contains('sprk'); 343 344 344 345 final Map<String, dynamic> recordJson; 345 346 final NSID collection; 346 347 347 348 if (isSprk) { 348 349 // Sprk comment 349 - final PostRecord sprkRecord = PostRecord( 350 + final sprkRecord = PostRecord( 350 351 text: text, 351 352 reply: RecordReplyRef( 352 353 root: StrongRef(uri: effectiveRootUri, cid: effectiveRootCid), ··· 359 360 collection = NSID.parse('so.sprk.feed.post'); 360 361 } else { 361 362 // Bluesky comment 362 - final bsky.PostRecord bskyRecord = bsky.PostRecord( 363 + final bskyRecord = bsky.PostRecord( 363 364 text: text, 364 365 createdAt: DateTime.now(), 365 366 reply: bsky.ReplyRef( ··· 391 392 _logger.d('Creating image post with ${imageFiles.length} images, crosspost: $crosspostToBsky'); 392 393 393 394 switch (imageFiles) { 394 - case List<XFile> files when files.isEmpty: 395 + case final List<XFile> files when files.isEmpty: 395 396 _logger.e('No images provided for image post'); 396 397 throw ArgumentError('At least one image is required for an image post.'); 397 398 default: ··· 402 403 } 403 404 404 405 if (_client.authRepository.atproto case final atproto?) { 405 - final List<Image> uploadedImageMaps = await uploadImages(imageFiles: imageFiles, altTexts: altTexts); 406 + final uploadedImageMaps = await uploadImages(imageFiles: imageFiles, altTexts: altTexts); 406 407 407 408 // Create Sprk post first 408 409 final record = PostRecord( ··· 439 440 Future<List<Image>> uploadImages({required List<XFile> imageFiles, Map<String, String>? altTexts}) async { 440 441 _logger.d('Processing ${imageFiles.length} images for upload'); 441 442 442 - final List<Image> uploadedImageMaps = []; 443 + final uploadedImageMaps = <Image>[]; 443 444 for (final imageFile in imageFiles) { 444 445 try { 445 446 _logger.d('Processing image: ${imageFile.name}'); ··· 469 470 470 471 // Add the uploaded image to our result list 471 472 uploadedImageMaps.add(Image(alt: altTexts?[imageFile.path] ?? '', image: response.data.blob)); 472 - break; 473 473 default: 474 474 _logger.e('Failed to upload image blob: ${response.status.code}'); 475 475 throw Exception('Blob upload failed for ${imageFile.name}: ${response.status.code}'); ··· 501 501 } 502 502 503 503 // Handle file:// URL scheme 504 - String cleanVideoPath = videoPath; 504 + var cleanVideoPath = videoPath; 505 505 if (videoPath.startsWith('file://')) { 506 506 cleanVideoPath = videoPath.replaceFirst('file://', ''); 507 507 } 508 508 509 509 // Validate the video file 510 510 final file = File(cleanVideoPath); 511 - if (!await file.exists()) { 511 + if (!file.existsSync()) { 512 512 throw Exception('Video file not found: $cleanVideoPath'); 513 513 } 514 514 ··· 539 539 540 540 // Parse the response 541 541 final responseData = jsonDecode(response.body); 542 - Blob blob; 543 542 //{'jobStatus': {'blob': blob}} = responseData; this is how it should work in the lexicon 544 - blob = Blob.fromJson(responseData['blobRef']); 545 - return blob; 543 + return Blob.fromJson(responseData['blobRef'] as Map<String, dynamic>); 546 544 }); 547 545 } 548 546 ··· 556 554 _logger.d('Crossposting to Bluesky with ${sparkImages.length} images'); 557 555 558 556 // Convert Spark images to Bluesky images and handle 4-image limit 559 - final List<bsky.Image> bskyImages = []; 560 - final maxBskyImages = 4; 557 + final bskyImages = <bsky.Image>[]; 558 + const maxBskyImages = 4; 561 559 final imagesToUse = sparkImages.take(maxBskyImages).toList(); 562 560 563 561 for (final sparkImage in imagesToUse) { ··· 570 568 } 571 569 572 570 // Determine final text for Bluesky post 573 - String finalText = text; 574 - List<bsky.Facet> facets = []; 571 + var finalText = text; 572 + final facets = <bsky.Facet>[]; 575 573 576 574 // If more than 4 images, add link to Spark post 577 575 if (sparkImages.length > maxBskyImages) { ··· 603 601 ), 604 602 ); 605 603 } else { 606 - final ellipsis = '...'; 604 + const ellipsis = '...'; 607 605 final croppedTextLength = availableTextLength - ellipsis.length; 608 606 final croppedText = text.substring(0, croppedTextLength); 609 607 finalText = '$croppedText$ellipsis$linkWithNewlines'; ··· 657 655 658 656 switch (response.status.code) { 659 657 case 200: 660 - _logger.i('Post deleted successfully: ${postUri.toString()}'); 658 + _logger.i('Post deleted successfully: $postUri'); 661 659 return true; 662 660 default: 663 661 _logger.e('Failed to delete post: ${response.status.code}'); ··· 703 701 record: record.toJson(), 704 702 ); 705 703 706 - switch (response.status) { 707 - case HttpStatus.ok: 708 - _logger.i('Video posted successfully: ${response.data.uri}'); 709 - return response.data; 710 - default: 711 - _logger.e('Failed to post video: ${response.status} ${response.data}'); 712 - throw Exception('Failed to post video: ${response.status} ${response.data}'); 704 + if (response.status == HttpStatus.ok) { 705 + _logger.i('Video posted successfully: ${response.data.uri}'); 706 + return response.data; 707 + } else { 708 + _logger.e('Failed to post video: ${response.status} ${response.data}'); 709 + throw Exception('Failed to post video: ${response.status} ${response.data}'); 713 710 } 714 711 }); 715 712 } ··· 736 733 final response = await bluesky.feed.getPostThread(uri: uri, depth: depth, parentHeight: parentHeight); 737 734 return Thread.fromBsky(thread: response.data.thread, uri: uri); 738 735 } 739 - final source = 'so.sprk.feed.getPostThread'; 736 + const source = 'so.sprk.feed.getPostThread'; 740 737 final response = await atproto.get( 741 738 NSID.parse(source), 742 739 parameters: {'uri': uri.toString(), 'depth': depth, 'parentHeight': parentHeight}, 743 740 headers: {'atproto-proxy': _client.sprkDid}, 744 741 to: (jsonMap) { 745 - return Thread.fromJson(jsonMap['thread'] as Map<String, dynamic>); 742 + return Thread.fromJson(jsonMap['thread']! as Map<String, dynamic>); 746 743 }, 747 744 ); 748 745 ··· 769 766 throw Exception('AtProto not initialized'); 770 767 } 771 768 772 - List<Label> labels = []; 769 + final labels = <Label>[]; 773 770 774 - final List<String> labelers = sources?.isNotEmpty == true ? sources! : ['did:plc:pbgyr67hftvpoqtvaurpsctc']; 771 + final labelers = sources?.isNotEmpty ?? true ? sources! : ['did:plc:pbgyr67hftvpoqtvaurpsctc']; 775 772 776 773 final parameters = {'uriPatterns': uris, 'sources': labelers, 'limit': limit, 'cursor': cursor}; 777 774 ··· 780 777 headers: {'atproto-proxy': 'did:plc:pbgyr67hftvpoqtvaurpsctc#atproto_labeler'}, 781 778 parameters: parameters, 782 779 to: (jsonMap) => jsonMap, 783 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 780 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 784 781 ); 785 - _logger.d('parameters: $parameters'); 786 - _logger.d('Labels retrieved: ${response.data}'); 782 + _logger 783 + ..d('parameters: $parameters') 784 + ..d('Labels retrieved: ${response.data}'); 787 785 788 - for (final label in response.data['labels'] as List<dynamic>) { 789 - final cleanLabel = label as Map<String, Object?>; 790 - cleanLabel.remove('sig'); // i am NOT going to convert that sig string into a UInt8List i am going to PASS OUT and DIE 791 - cleanLabel.putIfAbsent( 792 - 'src', 793 - () => 'did:plc:pbgyr67hftvpoqtvaurpsctc', 794 - ); // fix this when there's multiple labelers support. for now idgaf. src is null for some reason in the response 786 + for (final label in response.data['labels']! as List<dynamic>) { 787 + final cleanLabel = label as Map<String, Object?> 788 + ..remove('sig') // i am NOT going to convert that sig string into a UInt8List i am going to PASS OUT and DIE 789 + ..putIfAbsent( 790 + 'src', 791 + () => 'did:plc:pbgyr67hftvpoqtvaurpsctc', 792 + ); // fix this when there's multiple labelers support. for now idgaf. src is null for some reason in the response 795 793 labels.add(Label.fromJson(cleanLabel)); 796 794 } 797 795 ··· 823 821 to: (jsonMap) { 824 822 final storiesByAuthorMap = <ProfileViewBasic, List<StoryView>>{}; 825 823 826 - final storiesByAuthorArray = jsonMap['storiesByAuthor'] as List<dynamic>; 824 + final storiesByAuthorArray = jsonMap['storiesByAuthor']! as List<dynamic>; 827 825 for (final item in storiesByAuthorArray) { 828 826 final itemMap = item as Map<String, dynamic>; 829 827 final author = ProfileViewBasic.fromJson(itemMap['author'] as Map<String, dynamic>); ··· 859 857 NSID.parse('so.sprk.feed.getStories'), 860 858 parameters: {'uris': storyUris}, 861 859 headers: {'atproto-proxy': _client.sprkDid}, 862 - to: (jsonMap) => (jsonMap['stories'] as List<dynamic>).map((story) => StoryView.fromJson(story)).toList(), 860 + to: (jsonMap) => 861 + (jsonMap['stories']! as List<dynamic>).map((story) => StoryView.fromJson(story as Map<String, dynamic>)).toList(), 863 862 ); 864 863 865 864 return response.data; ··· 881 880 record: record.toJson(), 882 881 ); 883 882 884 - switch (response.status) { 885 - case HttpStatus.ok: 886 - _logger.i('Story posted successfully: ${response.data.uri}'); 887 - return response.data; 888 - default: 889 - _logger.e('Failed to post story: ${response.status} ${response.data}'); 890 - throw Exception('Failed to post story: ${response.status} ${response.data}'); 883 + if (response.status == HttpStatus.ok) { 884 + _logger.i('Story posted successfully: ${response.data.uri}'); 885 + return response.data; 886 + } else { 887 + _logger.e('Failed to post story: ${response.status} ${response.data}'); 888 + throw Exception('Failed to post story: ${response.status} ${response.data}'); 891 889 } 892 890 }); 893 891 }
+10 -8
lib/src/core/network/atproto/data/repositories/graph_repository_impl.dart
··· 2 2 3 3 import 'package:atproto/core.dart'; 4 4 import 'package:get_it/get_it.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/graph_models.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository.dart'; 6 7 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 7 8 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 8 9 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 9 - import 'package:sparksocial/src/core/network/atproto/data/models/graph_models.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 10 11 import 'package:sparksocial/src/features/settings/ui/pages/profile_settings_page.dart'; 11 12 12 13 /// Implementation of Graph-related API endpoints 13 14 class GraphRepositoryImpl implements GraphRepository { 14 - final SprkRepository _client; 15 - late final SettingsRepository _settingsRepository; 16 - final _logger = GetIt.instance<LogService>().getLogger('GraphRepository'); 17 - 18 15 GraphRepositoryImpl(this._client) { 19 16 _logger.v('GraphRepository initialized'); 20 17 } 18 + final SprkRepository _client; 19 + late final SettingsRepository _settingsRepository; 20 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('GraphRepository'); 21 21 22 22 @override 23 23 Future<FollowersResponse> getFollowers(String did) async { ··· 39 39 parameters: {'actor': did}, 40 40 headers: {'atproto-proxy': _client.sprkDid}, 41 41 to: (jsonMap) => jsonMap, 42 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 42 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 43 43 ); 44 44 _logger.d('Followers retrieved successfully'); 45 45 return FollowersResponse.fromJson(result.data as Map<String, dynamic>); ··· 66 66 parameters: {'actor': did}, 67 67 headers: {'atproto-proxy': _client.sprkDid}, 68 68 to: (jsonMap) => jsonMap, 69 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 69 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 70 70 ); 71 71 _logger.d('Follows retrieved successfully'); 72 72 return FollowsResponse.fromJson(result.data as Map<String, dynamic>); ··· 94 94 throw Exception('Session DID not available'); 95 95 } 96 96 try { 97 + /// goofy late check to ensure settings repository is initialized 98 + // ignore: unnecessary_statements 97 99 _settingsRepository; // goofy late check 98 100 } catch (e) { 99 101 _settingsRepository = GetIt.instance<SettingsRepository>(); ··· 116 118 throw Exception('Already following this user'); 117 119 } 118 120 119 - final followRecord = {"\$type": recordType, "subject": did, "createdAt": DateTime.now().toUtc().toIso8601String()}; 121 + final followRecord = {r'$type': recordType, 'subject': did, 'createdAt': DateTime.now().toUtc().toIso8601String()}; 120 122 121 123 final result = await atproto.repo.createRecord(collection: collection, record: followRecord); 122 124
+8 -9
lib/src/core/network/atproto/data/repositories/labeler_repository_impl.dart
··· 1 1 import 'dart:convert'; 2 2 3 3 import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:get_it/get_it.dart'; 4 5 import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/data/repositories/labeler_repository.dart'; 6 7 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 7 8 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 8 - import 'package:get_it/get_it.dart'; 9 - 9 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 10 10 11 11 class LabelerRepositoryImpl extends LabelerRepository { 12 - final SprkRepository _client; 13 - final _logger = GetIt.instance<LogService>().getLogger('LabelerAPI'); 14 - 15 12 LabelerRepositoryImpl(this._client) { 16 13 _logger.v('LabelerAPI initialized'); 17 14 } 15 + final SprkRepository _client; 16 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('LabelerAPI'); 18 17 19 18 @override 20 19 Future<LabelerView> getServices(List<String> dids) async { ··· 35 34 NSID.parse('so.sprk.labeler.getServices'), 36 35 parameters: {'dids': dids, 'detailed': false}, 37 36 headers: {'atproto-proxy': _client.sprkDid}, 38 - to: (jsonMap) => LabelerView.fromJson(jsonMap), 39 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 37 + to: LabelerView.fromJson, 38 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 40 39 ); 41 40 if (result.status != HttpStatus.ok) { 42 41 _logger.e('Failed to retrieve labeler services for DIDs: $dids'); ··· 66 65 NSID.parse('so.sprk.labeler.getServices'), 67 66 parameters: {'dids': dids, 'detailed': true}, 68 67 headers: {'atproto-proxy': _client.sprkDid}, 69 - to: (jsonMap) => LabelerViewDetailed.fromJson(jsonMap), 70 - adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 68 + to: LabelerViewDetailed.fromJson, 69 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 71 70 ); 72 71 if (result.status != HttpStatus.ok) { 73 72 _logger.e('Failed to retrieve labeler services for DIDs: $dids');
+11 -11
lib/src/core/network/atproto/data/repositories/repo_repository_impl.dart
··· 1 - import 'dart:typed_data'; 2 1 import 'dart:convert'; 2 + import 'dart:typed_data'; 3 3 4 - import 'package:atproto/core.dart'; 5 4 import 'package:atproto/atproto.dart'; 5 + import 'package:atproto/core.dart'; 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:http/http.dart' as http; 8 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository.dart'; 9 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 9 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository_impl.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 12 12 13 /// Repository-related API endpoints implementation 13 14 class RepoRepositoryImpl implements RepoRepository { 14 - final SprkRepositoryImpl _client; 15 - final _logger = GetIt.instance<LogService>().getLogger('RepoAPI'); 16 - 17 15 RepoRepositoryImpl(this._client) { 18 16 _logger.v('RepoAPI initialized'); 19 17 } 18 + final SprkRepositoryImpl _client; 19 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('RepoAPI'); 20 20 21 21 @override 22 22 Future<({Record record, StrongRef strongRef})> getRecord({required AtUri uri}) async { ··· 97 97 98 98 // Delete cross-posted Bluesky counterpart if it exists 99 99 try { 100 - final String did = uri.hostname; 101 - final String rkey = uri.rkey; 102 - final AtUri blueskyUri = AtUri.parse('at://$did/app.bsky.feed.post/$rkey'); 100 + final did = uri.hostname; 101 + final rkey = uri.rkey; 102 + final blueskyUri = AtUri.parse('at://$did/app.bsky.feed.post/$rkey'); 103 103 104 104 _logger.d('Attempting to delete Bluesky counterpart post: $blueskyUri'); 105 105 ··· 214 214 if (subjectData is StrongRef) { 215 215 final strongRef = subjectData.toJson(); 216 216 body = { 217 - 'subject': {'\$type': 'com.atproto.repo.strongRef', 'uri': strongRef['uri'], 'cid': strongRef['cid']}, 217 + 'subject': {r'$type': 'com.atproto.repo.strongRef', 'uri': strongRef['uri'], 'cid': strongRef['cid']}, 218 218 'reasonType': reasonType.value, 219 219 }; 220 220 } else if (subjectData is RepoRef) { 221 221 body = { 222 - 'subject': {'\$type': 'com.atproto.admin.defs.repoRef', 'did': subjectData.did}, 222 + 'subject': {r'$type': 'com.atproto.admin.defs.repoRef', 'did': subjectData.did}, 223 223 'reasonType': reasonType.value, 224 224 }; 225 225 } else {
+2 -2
lib/src/core/network/atproto/data/repositories/sprk_repository.dart
··· 1 - import 'package:sparksocial/src/features/auth/auth.dart'; 2 1 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository.dart'; 3 - import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository.dart'; 4 2 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 5 3 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository.dart'; 6 4 import 'package:sparksocial/src/core/network/atproto/data/repositories/labeler_repository.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository.dart'; 6 + import 'package:sparksocial/src/features/auth/auth.dart'; 7 7 8 8 // All possible endpoints for the Sprk API should be in this contract 9 9 // The implementation should be in each feature's repository
+15 -16
lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart
··· 1 1 import 'package:get_it/get_it.dart'; 2 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 2 3 import 'package:sparksocial/src/core/config/app_config.dart'; 3 4 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 4 6 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 7 + import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository_impl.dart'; 5 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository.dart'; 9 + // Feature-specific repositories 10 + import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 6 11 import 'package:sparksocial/src/core/network/atproto/data/repositories/labeler_repository.dart'; 12 + import 'package:sparksocial/src/core/network/atproto/data/repositories/labeler_repository_impl.dart'; 7 13 import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository.dart'; 8 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 9 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 10 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 11 14 import 'package:sparksocial/src/core/network/atproto/data/repositories/repo_repository_impl.dart'; 12 - import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository_impl.dart'; 13 - import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 14 - import 'package:sparksocial/src/core/network/atproto/data/repositories/labeler_repository_impl.dart'; 15 - 16 - // Feature-specific repositories 17 - import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 15 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 16 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 17 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 18 18 19 19 /// Client for interacting with Spark API endpoints 20 20 class SprkRepositoryImpl implements SprkRepository { 21 + SprkRepositoryImpl(this._authRepository) : _sprkDid = _getSprkDid() { 22 + _logger.d('SprkRepository initialized with DID: $_sprkDid'); 23 + } 21 24 final AuthRepository _authRepository; 22 25 final String _sprkDid; 23 - final _logger = GetIt.instance<LogService>().getLogger('SprkRepository'); 26 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('SprkRepository'); 24 27 25 28 /// Get the authentication service 26 29 @override ··· 30 33 @override 31 34 String get sprkDid => _sprkDid; 32 35 33 - SprkRepositoryImpl(this._authRepository) : _sprkDid = _getSprkDid() { 34 - _logger.d('SprkRepository initialized with DID: $_sprkDid'); 35 - } 36 - 37 36 static String _getSprkDid() { 38 37 final sprkAppView = Uri.parse(AppConfig.appViewUrl); 39 - return "did:web:${sprkAppView.host}#sprk_appview"; 38 + return 'did:web:${sprkAppView.host}#sprk_appview'; 40 39 } 41 40 42 41 /// Execute API request with token expiration handling ··· 58 57 59 58 _logger.i('Token refreshed successfully, retrying API call'); 60 59 // Retry the call with the new token 61 - return await apiCall(); 60 + return apiCall(); 62 61 } 63 62 64 63 _logger.e('API call failed', error: e);
+2 -3
lib/src/core/network/messages/data/models/message_models.dart
··· 5 5 6 6 @freezed 7 7 class Embed with _$Embed { 8 - const Embed._(); 9 - 10 8 @JsonSerializable(explicitToJson: true) 11 9 const factory Embed({String? url, String? type, String? preview}) = _Embed; 10 + const Embed._(); 12 11 13 12 factory Embed.fromJson(Map<String, dynamic> json) => _$EmbedFromJson(json); 14 13 ··· 18 17 19 18 @freezed 20 19 class Message with _$Message { 21 - const Message._(); 22 20 @JsonSerializable(explicitToJson: true) 23 21 const factory Message({ 24 22 required int id, ··· 28 26 @JsonKey(name: 'timestampz') required DateTime timestamp, 29 27 List<Embed>? embed, 30 28 }) = _Message; 29 + const Message._(); 31 30 32 31 factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(json); 33 32 }
+12 -11
lib/src/core/network/messages/data/repository/messages_repository_impl.dart
··· 1 1 import 'dart:convert'; 2 + 2 3 import 'package:get_it/get_it.dart'; 3 4 import 'package:http/http.dart' as http; 4 5 import 'package:sparksocial/src/core/config/app_config.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Embed; 7 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 8 + import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; 6 9 import 'package:sparksocial/src/core/utils/utils.dart'; 7 10 import 'package:sparksocial/src/features/auth/auth.dart'; 8 - import 'messages_repository.dart'; 9 - import '../models/message_models.dart'; 10 11 11 12 class MessagesRepositoryImpl implements MessagesRepository { 12 - final AuthRepository _authRepository; 13 - late final SparkLogger _logger; 14 - 15 13 MessagesRepositoryImpl(this._authRepository) { 16 14 _logger = GetIt.I<LogService>().getLogger('MessagesRepository'); 17 15 } 16 + final AuthRepository _authRepository; 17 + late final SparkLogger _logger; 18 18 19 19 String? get accessToken => _authRepository.dmAccessToken; 20 20 21 21 Map<String, String> get _headers => { 22 22 'Content-Type': 'application/json', 23 - if (accessToken?.isNotEmpty == true) 'Authorization': 'Bearer $accessToken', 23 + if (accessToken?.isNotEmpty ?? true) 'Authorization': 'Bearer $accessToken', 24 24 }; 25 25 26 26 Future<void> _refreshIfExpired() async { ··· 50 50 51 51 if (response.statusCode == 200) { 52 52 final data = jsonDecode(response.body) as Map<String, dynamic>; 53 - final messagesList = (data['messages'] as List).map((json) => Message.fromJson(json)).toList(); 53 + final messagesList = (data['messages'] as List).map((json) => Message.fromJson(json as Map<String, dynamic>)).toList(); 54 54 55 55 return (messages: messagesList, cursor: data['cursor'] as String?); 56 56 } else if (response.statusCode == 401) { ··· 89 89 if (response.statusCode == 200) { 90 90 final userDid = _authRepository.session?.did; 91 91 final data = jsonDecode(response.body) as Map<String, dynamic>; 92 - final messages = (data['conversations'] as List).map((json) => Message.fromJson(json)).toList(); 92 + final messages = (data['conversations'] as List).map((json) => Message.fromJson(json as Map<String, dynamic>)).toList(); 93 93 final profileDids = (data['conversations'] as List) 94 94 .map((json) => json['sender_did'] != userDid ? json['sender_did'] as String : json['receiver_did'] as String) 95 95 .where((did) => did.startsWith('did:plc:')) ··· 125 125 final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/send'); 126 126 127 127 final response = await http.post(uri, headers: _headers, body: jsonEncode(requestBody), encoding: utf8); 128 - _logger.d('Response status code: ${response.statusCode}'); 129 - _logger.d('Response body: ${response.body}'); 128 + _logger 129 + ..d('Response status code: ${response.statusCode}') 130 + ..d('Response body: ${response.body}'); 130 131 131 132 if (response.statusCode == 200 || response.statusCode == 201) { 132 133 final data = jsonDecode(response.body) as Map<String, dynamic>; 133 - return Message.fromJson(data['message']); 134 + return Message.fromJson(data['message'] as Map<String, dynamic>); 134 135 } else if (response.statusCode == 401) { 135 136 await _refreshIfExpired(); 136 137
+4 -7
lib/src/core/routing/app_router.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 + import 'package:collection/collection.dart'; 2 3 import 'package:flutter/material.dart'; 3 - import 'package:sparksocial/src/core/routing/pages.dart'; 4 + import 'package:image_picker/image_picker.dart'; 4 5 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 + import 'package:sparksocial/src/core/routing/pages.dart'; 5 7 import 'package:video_player/video_player.dart'; 6 - import 'package:image_picker/image_picker.dart'; 7 - import 'package:collection/collection.dart'; 8 8 9 9 part 'app_router.gr.dart'; 10 10 ··· 14 14 @AutoRouterConfig() 15 15 class AppRouter extends RootStackRouter { 16 16 @override 17 - RouteType get defaultRouteType => RouteType.adaptive(); 17 + RouteType get defaultRouteType => const RouteType.adaptive(); 18 18 19 19 @override 20 20 List<AutoRoute> get routes => [ ··· 110 110 isScrollControlled: true, 111 111 backgroundColor: Colors.transparent, 112 112 useSafeArea: true, 113 - enableDrag: true, 114 - isDismissible: true, 115 113 ); 116 114 } 117 115 ··· 122 120 isScrollControlled: true, 123 121 backgroundColor: Colors.transparent, 124 122 useSafeArea: true, 125 - enableDrag: true, 126 123 ); 127 124 } 128 125 }
+21 -23
lib/src/core/routing/pages.dart
··· 1 - library; 2 - 3 1 export 'package:sparksocial/src/features/auth/ui/pages/auth_prompt_page.dart'; 4 2 export 'package:sparksocial/src/features/auth/ui/pages/login_page.dart'; 5 - export 'package:sparksocial/src/features/auth/ui/pages/register_page.dart'; 6 3 export 'package:sparksocial/src/features/auth/ui/pages/onboarding_page.dart'; 7 - export 'package:sparksocial/src/features/profile/ui/pages/profile_page.dart'; 8 - export 'package:sparksocial/src/features/profile/ui/pages/edit_profile_page.dart'; 9 - export 'package:sparksocial/src/features/profile/ui/pages/profile_videos_page.dart'; 10 - export 'package:sparksocial/src/features/profile/ui/pages/profile_photos_page.dart'; 11 - export 'package:sparksocial/src/features/profile/ui/pages/standalone_profile_feed_page.dart'; 4 + export 'package:sparksocial/src/features/auth/ui/pages/register_page.dart'; 5 + export 'package:sparksocial/src/features/comments/ui/pages/comments_page.dart'; 6 + export 'package:sparksocial/src/features/comments/ui/pages/replies_page.dart'; 12 7 export 'package:sparksocial/src/features/feed/ui/pages/feed_page.dart'; 13 8 export 'package:sparksocial/src/features/feed/ui/pages/feeds_page.dart'; 9 + export 'package:sparksocial/src/features/feed/ui/pages/standalone_post_page.dart'; 10 + export 'package:sparksocial/src/features/home/ui/pages/empty_page.dart'; 14 11 export 'package:sparksocial/src/features/home/ui/pages/main_page.dart'; 15 - export 'package:sparksocial/src/features/messages/ui/pages/messages_page.dart'; 12 + export 'package:sparksocial/src/features/home/ui/pages/placeholder_page.dart'; 16 13 export 'package:sparksocial/src/features/messages/ui/pages/chat_page.dart'; 14 + export 'package:sparksocial/src/features/messages/ui/pages/messages_page.dart'; 17 15 export 'package:sparksocial/src/features/messages/ui/pages/new_chat_search_page.dart'; 16 + export 'package:sparksocial/src/features/posting/ui/pages/create_video_page.dart'; 17 + export 'package:sparksocial/src/features/posting/ui/pages/image_review_page.dart'; 18 + export 'package:sparksocial/src/features/posting/ui/pages/story_review_page.dart'; 19 + export 'package:sparksocial/src/features/posting/ui/pages/video_playback_page.dart'; 20 + export 'package:sparksocial/src/features/posting/ui/pages/video_review_page.dart'; 21 + export 'package:sparksocial/src/features/profile/ui/pages/edit_profile_page.dart'; 22 + export 'package:sparksocial/src/features/profile/ui/pages/profile_page.dart'; 23 + export 'package:sparksocial/src/features/profile/ui/pages/profile_photos_page.dart'; 24 + export 'package:sparksocial/src/features/profile/ui/pages/profile_videos_page.dart'; 25 + export 'package:sparksocial/src/features/profile/ui/pages/standalone_profile_feed_page.dart'; 26 + export 'package:sparksocial/src/features/profile/ui/pages/user_profile_page.dart'; 18 27 export 'package:sparksocial/src/features/search/ui/pages/search_page.dart'; 19 - export 'package:sparksocial/src/features/splash/ui/pages/splash_page.dart'; 20 - export 'package:sparksocial/src/features/home/ui/pages/empty_page.dart'; 28 + export 'package:sparksocial/src/features/settings/ui/pages/feed_list_page.dart'; 21 29 export 'package:sparksocial/src/features/settings/ui/pages/feed_settings_page.dart'; 22 - export 'package:sparksocial/src/features/home/ui/pages/placeholder_page.dart'; 23 - export 'package:sparksocial/src/features/settings/ui/pages/feed_list_page.dart'; 24 30 export 'package:sparksocial/src/features/settings/ui/pages/label_settings_page.dart'; 25 - export 'package:sparksocial/src/features/comments/ui/pages/comments_page.dart'; 26 - export 'package:sparksocial/src/features/feed/ui/pages/standalone_post_page.dart'; 27 - export 'package:sparksocial/src/features/comments/ui/pages/replies_page.dart'; 28 - export 'package:sparksocial/src/features/profile/ui/pages/user_profile_page.dart'; 31 + export 'package:sparksocial/src/features/settings/ui/pages/profile_settings_page.dart'; 32 + export 'package:sparksocial/src/features/splash/ui/pages/splash_page.dart'; 29 33 export 'package:sparksocial/src/features/stories/ui/pages/all_stories_page.dart'; 30 - export 'package:sparksocial/src/features/posting/ui/pages/video_playback_page.dart'; 31 - export 'package:sparksocial/src/features/posting/ui/pages/video_review_page.dart'; 32 - export 'package:sparksocial/src/features/posting/ui/pages/story_review_page.dart'; 33 - export 'package:sparksocial/src/features/posting/ui/pages/create_video_page.dart'; 34 - export 'package:sparksocial/src/features/posting/ui/pages/image_review_page.dart'; 35 - export 'package:sparksocial/src/features/settings/ui/pages/profile_settings_page.dart'; 36 34 export 'package:sparksocial/src/features/stories/ui/pages/author_stories_page.dart'; 37 35 export 'package:sparksocial/src/features/stories/ui/pages/story_page.dart';
+15 -14
lib/src/core/storage/cache/cache_manager_impl.dart
··· 1 1 import 'dart:io'; 2 2 import 'dart:typed_data'; 3 + 3 4 import 'package:atproto/core.dart'; 4 - import 'package:path_provider/path_provider.dart'; 5 5 import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 6 - import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 7 6 import 'package:get_it/get_it.dart'; 7 + import 'package:path_provider/path_provider.dart'; 8 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 + import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 9 10 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 10 11 11 12 /// Manages temporary cache files for the application 12 13 class CacheManagerImpl implements CacheManagerInterface { 13 - /// Singleton instance 14 - static final CacheManagerImpl _instance = CacheManagerImpl._(); 15 - 16 - /// Default cache manager for most files 17 - late final CacheManager cacheManager; 18 - 19 - /// Logger for debugging 20 - late final SparkLogger _logger; 21 - 22 14 /// Private constructor 23 15 CacheManagerImpl._() { 24 16 cacheManager = CacheManager( ··· 30 22 _logger = GetIt.instance<LogService>().getLogger('CacheManager'); 31 23 } 32 24 25 + /// Singleton instance 26 + static final CacheManagerImpl _instance = CacheManagerImpl._(); 27 + 28 + /// Default cache manager for most files 29 + late final CacheManager cacheManager; 30 + 31 + /// Logger for debugging 32 + late final SparkLogger _logger; 33 + 33 34 /// Get the singleton instance 34 35 static CacheManagerImpl get instance => _instance; 35 36 ··· 48 49 return null; 49 50 } 50 51 51 - /// Download blob from AT Protocol API 52 + /// Download blob from AT Protocol API 52 53 Future<Uint8List> _downloadAtProtocolBlob(String did, String cid) async { 53 54 try { 54 55 final sprkRepository = GetIt.instance<SprkRepository>(); ··· 151 152 @override 152 153 Future<int> getCacheSize() async { 153 154 final cacheDir = await getTemporaryDirectory(); 154 - return await _calculateDirSize(cacheDir); 155 + return _calculateDirSize(cacheDir); 155 156 } 156 157 157 158 /// Clear all cached files ··· 178 179 179 180 /// Helper method to calculate directory size 180 181 Future<int> _calculateDirSize(Directory dir) async { 181 - int totalSize = 0; 182 + var totalSize = 0; 182 183 try { 183 184 if (dir.existsSync()) { 184 185 dir.listSync(recursive: true, followLinks: false).forEach((FileSystemEntity entity) {
+1 -1
lib/src/core/storage/cache/cache_manager_interface.dart
··· 11 11 Future<File?> getCachedFile(String url); 12 12 13 13 /// Store a file in the cache with the given key 14 - Future<void> putFile(String url, Uint8List fileBytes); 14 + Future<void> putFile(String url, Uint8List fileBytes); 15 15 16 16 /// Remove a specific file from cache 17 17 Future<void> removeFile(String url);
+6 -11
lib/src/core/storage/cache/download_manager_impl.dart
··· 1 1 import 'package:cached_network_image/cached_network_image.dart'; 2 + import 'package:collection/collection.dart'; 2 3 import 'package:get_it/get_it.dart'; 3 4 import 'package:pool/pool.dart'; 4 5 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; ··· 8 9 import 'package:sparksocial/src/core/storage/storage.dart'; 9 10 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 10 11 import 'package:sparksocial/src/features/feed/providers/feed_state.dart'; 11 - import 'package:collection/collection.dart'; 12 12 13 13 class DownloadManagerImpl implements DownloadManagerInterface { 14 14 DownloadManagerImpl() : _pool = Pool(FeedState.poolSize) { ··· 75 75 final newTasks = <DownloadTask>[]; 76 76 77 77 while (_tasks.isNotEmpty) { 78 - var task = _tasks.removeFirst(); 78 + final task = _tasks.removeFirst(); 79 79 if (task.status == DownloadTaskStatus.pending) { 80 80 // Try to acquire a pool resource. If pool is full, withResource will wait. 81 81 // We want to submit to the pool and let it manage, not block _processQueue. ··· 83 83 // that would make _processQueue sequential for task submission to pool. 84 84 // Instead, we launch it and let the pool handle concurrency. 85 85 86 - _pool 86 + await _pool 87 87 .withResource(() => _executeTask(task)) 88 88 .then((_) { 89 89 // This 'then' block executes after _executeTask is fully done ··· 93 93 .catchError((e, s) { 94 94 // This catchError is for unexpected errors from _pool.withResource itself, 95 95 // or if _executeTask re-throws an error not caught internally. 96 - _logger.e('Error from pool for task ${task.uri}: $e', error: e, stackTrace: s); 96 + _logger.e('Error from pool for task ${task.uri}: $e', error: e, stackTrace: s as StackTrace); 97 97 // Ensure task is marked as failed and removed if not already 98 98 if (task.status != DownloadTaskStatus.failed && task.status != DownloadTaskStatus.completed) { 99 99 task.status = DownloadTaskStatus.failed; ··· 163 163 throw Exception('Video file was not properly cached after download: ${task.post.videoUrl}'); 164 164 } 165 165 _logger.d('Video file successfully cached: ${task.post.videoUrl}'); 166 - break; 167 166 case EmbedViewImage(): 168 - for (String url in task.post.imageUrls) { 167 + for (final url in task.post.imageUrls) { 169 168 // Download the image and verify it's cached 170 169 final fileInfo = await CachedNetworkImageProvider.defaultCacheManager.downloadFile(url, key: url); 171 170 if (fileInfo.statusCode != 200) { 172 171 _logger.w('Image file was not properly cached after download: $url'); 173 172 } 174 173 } 175 - break; 176 174 case EmbedViewBskyRecordWithMedia(:final media): 177 175 // Handle nested media in record with media embeds 178 176 switch (media) { ··· 190 188 throw Exception('Video file was not properly cached after download: ${task.post.videoUrl}'); 191 189 } 192 190 _logger.d('Video file successfully cached: ${task.post.videoUrl}'); 193 - break; 194 191 case EmbedViewImage() || EmbedViewBskyImages(): 195 - for (String url in task.post.imageUrls) { 192 + for (final url in task.post.imageUrls) { 196 193 // Download the image and verify it's cached 197 194 final fileInfo = await CachedNetworkImageProvider.defaultCacheManager.downloadFile(url, key: url); 198 195 if (fileInfo.statusCode != 200) { 199 196 _logger.w('Image file was not properly cached after download: $url'); 200 197 } 201 198 } 202 - break; 203 199 case _: 204 200 throw Exception('Unsupported media type: ${media.runtimeType}'); 205 201 } 206 - break; 207 202 case _: 208 203 throw Exception('Unsupported media type: ${task.post.embed.runtimeType}'); 209 204 }
-1
lib/src/core/storage/cache/download_manager_interface.dart
··· 50 50 } 51 51 52 52 abstract class DownloadManagerInterface { 53 - 54 53 /// Sets the currently active feed. Tasks related to the active feed 55 54 /// may be prioritized. 56 55 ///
+38 -39
lib/src/core/storage/cache/sql_cache_impl.dart
··· 2 2 import 'dart:convert'; 3 3 4 4 import 'package:atproto/atproto.dart'; 5 + import 'package:atproto_core/atproto_core.dart'; 5 6 import 'package:get_it/get_it.dart'; 6 7 import 'package:path/path.dart'; 8 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 7 9 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 8 10 import 'package:sparksocial/src/core/storage/storage.dart'; 9 11 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 10 12 import 'package:sqflite/sqflite.dart'; 11 - 12 - import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 13 - import 'package:atproto_core/atproto_core.dart'; 14 13 15 14 // --- Post Table --- 16 15 const String _tablePosts = 'cached_posts'; ··· 43 42 const String _columnAssociationOrder = 'association_order'; // INTEGER, for ordering posts within a feed 44 43 45 44 class SQLCacheImpl implements SQLCacheInterface { 46 - static Database? _database; 47 - late final SparkLogger _logger; 48 - 49 45 SQLCacheImpl() { 50 46 _logger = GetIt.instance<LogService>().getLogger('SQLCacheImpl'); 51 47 } 48 + static Database? _database; 49 + late final SparkLogger _logger; 52 50 53 51 Future<Database> get database async { 54 52 if (_database != null) return _database!; ··· 57 55 } 58 56 59 57 Future<Database> _initDB() async { 60 - String path = join(await getDatabasesPath(), 'sparksocial_sql_cache.db'); 61 - return await openDatabase( 58 + final path = join(await getDatabasesPath(), 'sparksocial_sql_cache.db'); 59 + return openDatabase( 62 60 path, 63 61 version: 2, // Increment this if you change the schema 64 62 onCreate: _onCreate, ··· 67 65 } 68 66 69 67 Future<void> _onCreate(Database db, int version) async { 70 - Batch batch = db.batch(); 68 + final batch = db.batch(); 71 69 // Posts Table 72 70 batch.execute(''' 73 71 CREATE TABLE $_tablePosts ( ··· 194 192 whereArgs: uris.map((uri) => uri.toString()).toList(), 195 193 ); 196 194 197 - return maps.map((map) => _mapToPostView(map)).toList(); 195 + return maps.map(_mapToPostView).toList(); 198 196 } 199 197 200 198 /// Given a list of AtUris, returns a sub-list containing only those URIs ··· 226 224 limit: limit, 227 225 offset: offset, 228 226 ); 229 - return maps.map((map) => _mapToPostView(map)).toList(); 227 + return maps.map(_mapToPostView).toList(); 230 228 } 231 229 232 230 /// Converts a Map from SQLite back to a PostView. ··· 235 233 return PostView( 236 234 uri: AtUri.parse(map[_columnUri] as String), 237 235 cid: map[_columnString] as String, 238 - author: ProfileViewBasic.fromJson(jsonDecode(map[_columnAuthor] as String)), 239 - record: PostRecord.fromJson(jsonDecode(map[_columnRecord] as String)), 236 + author: ProfileViewBasic.fromJson(jsonDecode(map[_columnAuthor] as String) as Map<String, dynamic>), 237 + record: PostRecord.fromJson(jsonDecode(map[_columnRecord] as String) as Map<String, dynamic>), 240 238 isRepost: (map[_columnIsRepost] as int) == 1, 241 239 indexedAt: DateTime.parse(map[_columnIndexedAt] as String), 242 240 likeCount: map[_columnLikeCount] as int?, 243 241 replyCount: map[_columnReplyCount] as int?, 244 242 repostCount: map[_columnRepostCount] as int?, 245 243 quoteCount: map[_columnQuoteCount] as int?, 246 - labels: 247 - map[_columnLabels] != null 248 - ? (jsonDecode(map[_columnLabels] as String) as List<dynamic>) 249 - .map((e) => Label.fromJson(e as Map<String, dynamic>)) 250 - .toList() 251 - : null, 252 - viewer: map[_columnViewer] != null ? Viewer.fromJson(jsonDecode(map[_columnViewer] as String)) : null, 253 - embed: map[_columnEmbed] != null ? EmbedView.fromJson(jsonDecode(map[_columnEmbed] as String)) : null, 244 + labels: map[_columnLabels] != null 245 + ? (jsonDecode(map[_columnLabels] as String) as List<dynamic>) 246 + .map((e) => Label.fromJson(e as Map<String, dynamic>)) 247 + .toList() 248 + : null, 249 + viewer: map[_columnViewer] != null ? Viewer.fromJson(jsonDecode(map[_columnViewer] as String) as Map<String, dynamic>) : null, 250 + embed: map[_columnEmbed] != null ? EmbedView.fromJson(jsonDecode(map[_columnEmbed] as String) as Map<String, dynamic>) : null, 254 251 ); 255 252 } 256 253 ··· 301 298 302 299 // 2. Add new associations in order 303 300 final batch = txn.batch(); 304 - for (int i = 0; i < postUris.length; i++) { 301 + for (var i = 0; i < postUris.length; i++) { 305 302 batch.insert( 306 303 _tableFeedPostAssociations, 307 304 { ··· 341 338 Future<List<PostView>> getPostsForFeed(Feed feed, {int? limit, int? offset}) async { 342 339 final feedIdentifier = feed.identifier; 343 340 final db = await database; 344 - List<dynamic> arguments = [feedIdentifier]; 345 - String limitClause = ''; 341 + final arguments = <dynamic>[feedIdentifier]; 342 + var limitClause = ''; 346 343 347 344 if (limit != null) { 348 345 limitClause += ' LIMIT ?'; ··· 359 356 arguments.add(offset); 360 357 } 361 358 362 - final String sql = ''' 359 + final sql = 360 + ''' 363 361 SELECT p.* 364 362 FROM $_tablePosts p 365 363 INNER JOIN $_tableFeedPostAssociations fpa ON p.$_columnUri = fpa.$_columnPostUriFK ··· 369 367 '''; 370 368 371 369 final List<Map<String, dynamic>> maps = await db.rawQuery(sql, arguments); 372 - return maps.map((map) => _mapToPostView(map)).toList(); 370 + return maps.map(_mapToPostView).toList(); 373 371 } 374 372 375 373 /// Retrieves post URIs for a specific feed, ordered by their association order ··· 377 375 /// 378 376 /// Does NOT update the `lastAccessed` timestamp of any posts. 379 377 /// 380 - /// - [feedIdentifier]: The identifier of the feed. 378 + /// - [feed]: The identifier of the feed. 381 379 /// - [limit]: The maximum number of URIs to retrieve. If null, no limit. 382 380 /// - [offset]: The number of URIs to skip before starting to retrieve. Requires [limit] to be set. 383 381 @override 384 382 Future<List<String>> getUrisForFeed(Feed feed, {int? limit, int? offset}) async { 385 383 final feedIdentifier = feed.identifier; 386 384 final db = await database; 387 - List<dynamic> arguments = [feedIdentifier]; 388 - String limitClause = ''; 385 + final arguments = <dynamic>[feedIdentifier]; 386 + var limitClause = ''; 389 387 390 388 if (limit != null) { 391 389 limitClause += ' LIMIT ?'; ··· 399 397 arguments.add(offset); 400 398 } 401 399 402 - final String sql = ''' 400 + final sql = 401 + ''' 403 402 SELECT p.$_columnUri 404 403 FROM $_tablePosts p 405 404 INNER JOIN $_tableFeedPostAssociations fpa ON p.$_columnUri = fpa.$_columnPostUriFK ··· 429 428 whereArgs: [feedIdentifier], 430 429 ); 431 430 432 - int currentMaxOrder = -1; // Start before 0 if feed is empty 431 + var currentMaxOrder = -1; // Start before 0 if feed is empty 433 432 if (maxOrderResult.isNotEmpty && maxOrderResult.first['max_order'] != null) { 434 433 currentMaxOrder = maxOrderResult.first['max_order'] as int; 435 434 } 436 435 437 436 // 2. Add new associations with incrementing order 438 437 final batch = txn.batch(); 439 - for (int i = 0; i < postUris.length; i++) { 438 + for (var i = 0; i < postUris.length; i++) { 440 439 batch.insert(_tableFeedPostAssociations, { 441 440 _columnFeedIdentifierFK: feedIdentifier, 442 441 _columnPostUriFK: postUris[i], ··· 472 471 final db = await database; 473 472 final cacheManager = GetIt.instance<CacheManagerInterface>(); 474 473 final countResult = await db.rawQuery('SELECT COUNT(*) FROM $_tablePosts'); 475 - final int currentSize = Sqflite.firstIntValue(countResult) ?? 0; 476 - int deletedCount = 0; 474 + final currentSize = Sqflite.firstIntValue(countResult) ?? 0; 475 + var deletedCount = 0; 477 476 478 477 if (currentSize > postsToKeep) { 479 - final int numToDelete = currentSize - postsToKeep; 478 + final numToDelete = currentSize - postsToKeep; 480 479 // Find the URIs of the posts to delete (oldest ones) 481 480 final List<Map<String, dynamic>> toDeleteMaps = await db.query( 482 481 _tablePosts, ··· 487 486 488 487 if (toDeleteMaps.isNotEmpty) { 489 488 for (final map in toDeleteMaps) { 490 - final String uri = map[_columnUri] as String; 489 + final uri = map[_columnUri] as String; 491 490 await cacheManager.removeFile(uri); 492 491 } 493 492 494 - final List<String> urisToDelete = toDeleteMaps.map((map) => map[_columnUri] as String).toList(); 493 + final urisToDelete = toDeleteMaps.map((map) => map[_columnUri] as String).toList(); 495 494 final placeholders = List.generate(urisToDelete.length, (index) => '?').join(','); 496 495 deletedCount = await db.delete(_tablePosts, where: '$_columnUri IN ($placeholders)', whereArgs: urisToDelete); 497 496 } ··· 514 513 whereArgs: [cutoffTimestamp], 515 514 ); 516 515 for (final map in toDeleteMaps) { 517 - final String uri = map[_columnUri]; 516 + final uri = map[_columnUri] as String; 518 517 await cacheManager.removeFile(uri); 519 518 } 520 519 521 - return await db.delete(_tablePosts, where: '$_columnLastAccessed < ?', whereArgs: [cutoffTimestamp]); 520 + return db.delete(_tablePosts, where: '$_columnLastAccessed < ?', whereArgs: [cutoffTimestamp]); 522 521 } 523 522 524 523 /// Clears the entire PostView cache from the database.
+1 -2
lib/src/core/storage/cache/sql_cache_interface.dart
··· 1 1 import 'dart:async'; 2 2 3 + import 'package:atproto_core/atproto_core.dart'; // For AtUri 3 4 // We need models for method signatures 4 5 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 5 - import 'package:atproto_core/atproto_core.dart'; // For AtUri 6 6 7 7 abstract class SQLCacheInterface { 8 8 /// Caches a single [PostView]. If it already exists, it's updated. ··· 71 71 /// The new posts are added after the existing posts in the feed's order. 72 72 /// It's assumed that the [PostView]s corresponding to `postUris` are already cached. 73 73 Future<void> appendPostsToFeed(Feed feed, List<String> postUris); 74 - 75 74 76 75 /// Clears all associations with a specific [Feed] from the cache. 77 76 /// Neither the feed metadata nor the posts are removed, only the associations.
+4 -4
lib/src/core/storage/preferences/secure_storage.dart
··· 1 1 import 'dart:convert'; 2 + 2 3 import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 4 + import 'package:sparksocial/src/core/storage/preferences/local_storage_interface.dart'; 3 5 import 'package:synchronized/synchronized.dart'; 4 - import 'local_storage_interface.dart'; 5 6 6 7 /// Implementation of LocalStorageInterface using FlutterSecureStorage 7 8 /// for storing sensitive data like tokens, credentials, etc. 8 9 class SecureStorage implements LocalStorageInterface { 9 - final FlutterSecureStorage _secureStorage; 10 - final Lock _lock = Lock(); 11 - 12 10 /// Creates a new SecureStorage instance 13 11 /// If no secureStorage is provided, a default one will be created 14 12 SecureStorage({FlutterSecureStorage? secureStorage}) : _secureStorage = secureStorage ?? const FlutterSecureStorage(); 13 + final FlutterSecureStorage _secureStorage; 14 + final Lock _lock = Lock(); 15 15 16 16 @override 17 17 Future<void> setString(String key, String value) async {
+18 -21
lib/src/core/storage/preferences/settings_repository_impl.dart
··· 1 1 import 'package:get_it/get_it.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 3 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 2 4 import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 3 6 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 4 7 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 5 - import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 8 import 'package:sparksocial/src/core/storage/storage.dart'; 7 9 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 8 10 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 9 11 import 'package:sparksocial/src/features/settings/ui/pages/profile_settings_page.dart'; 10 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 11 - import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 12 12 13 13 class SettingsRepositoryImpl implements SettingsRepository { 14 - late final SQLCacheInterface _sqlCache; 15 - late final StorageManager _storageManager; 16 - late final SparkLogger _logger; 17 - late final SprkRepository _sprkRepository; 18 - 19 14 SettingsRepositoryImpl() { 20 15 _sqlCache = GetIt.instance<SQLCacheInterface>(); 21 16 _storageManager = GetIt.instance<StorageManager>(); ··· 23 18 _sprkRepository = GetIt.instance<SprkRepository>(); 24 19 _setupDefaultLabelPreferences(); 25 20 } 21 + late final SQLCacheInterface _sqlCache; 22 + late final StorageManager _storageManager; 23 + late final SparkLogger _logger; 24 + late final SprkRepository _sprkRepository; 26 25 27 26 Future<void> _setupDefaultLabelPreferences() async { 28 27 if (await _storageManager.preferences.getObject<bool>(StorageKeys.defaultLabelsAreSetupKey) ?? false) { ··· 211 210 if (feedsJson == null) { 212 211 _logger.d('No feeds found in storage, using defaults'); 213 212 final defaultFeeds = [ 214 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.following), 215 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.forYou), 216 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 213 + const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.following), 214 + const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.forYou), 215 + const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 217 216 ]; 218 217 await setFeeds(defaultFeeds); 219 218 return defaultFeeds; ··· 228 227 _logger.e('Error deserializing feeds: $e'); 229 228 // If deserialization fails, return defaults 230 229 final defaultFeeds = [ 231 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.following), 232 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.forYou), 233 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 230 + const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.following), 231 + const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.forYou), 232 + const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 234 233 ]; 235 234 await setFeeds(defaultFeeds); 236 235 return defaultFeeds; ··· 243 242 final activeFeedJson = await _storageManager.preferences.getObject<Map<String, dynamic>>(StorageKeys.activeFeedKey); 244 243 if (activeFeedJson == null) { 245 244 _logger.d('No active feed found in storage, using default (Latest)'); 246 - return Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk); 245 + return const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk); 247 246 } 248 247 249 248 try { ··· 253 252 } catch (e) { 254 253 _logger.e('Error deserializing active feed: $e'); 255 254 // If deserialization fails, return default 256 - return Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk); 255 + return const Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk); 257 256 } 258 257 } 259 258 ··· 296 295 labelers.add('did:plc:pbgyr67hftvpoqtvaurpsctc'); 297 296 } 298 297 await _storageManager.preferences.setObject<List<String>>(StorageKeys.followedLabelers, labelers); 299 - for (var labelPreference in labelPreferences) { 298 + for (final labelPreference in labelPreferences) { 300 299 await _storageManager.preferences.setObject<Map<String, dynamic>>( 301 300 '${StorageKeys.labelPreferenceKey}_${labelPreference.value}', 302 301 labelPreference.toJson(), ··· 312 311 if (rawJson == null) { 313 312 throw Exception('Label preference not found'); 314 313 } 315 - 314 + 316 315 try { 317 316 return LabelPreference.fromJson(rawJson); 318 317 } catch (e) { ··· 321 320 } 322 321 } 323 322 324 - 325 - 326 323 @override 327 324 Future<void> setLabelPreference(String value, Blurs blurs, Severity severity, bool adultOnly, Setting setting) async { 328 325 // Check if a preference already exists 329 326 final existingRawJson = await _storageManager.preferences.getObject<Map<String, dynamic>>( 330 327 '${StorageKeys.labelPreferenceKey}_$value', 331 328 ); 332 - 329 + 333 330 if (existingRawJson != null) { 334 331 try { 335 332 // Update existing preference
+2 -3
lib/src/core/storage/preferences/shared_prefs_storage.dart
··· 1 1 import 'dart:convert'; 2 2 import 'package:shared_preferences/shared_preferences.dart'; 3 - import 'local_storage_interface.dart'; 3 + import 'package:sparksocial/src/core/storage/preferences/local_storage_interface.dart'; 4 4 5 5 /// Implementation of LocalStorageInterface using SharedPreferences 6 6 class SharedPrefsStorage implements LocalStorageInterface { 7 + SharedPrefsStorage(this._prefs); 7 8 final SharedPreferences _prefs; 8 - 9 - SharedPrefsStorage(this._prefs); 10 9 11 10 /// Factory constructor to create a SharedPrefsStorage instance 12 11 static Future<SharedPrefsStorage> create() async {
+2 -3
lib/src/core/storage/preferences/storage_constants.dart
··· 1 1 /// Storage keys used throughout the application 2 2 class StorageKeys { 3 + // Private constructor to prevent instantiation 4 + StorageKeys._(); 3 5 static const String userSession = 'user_session'; 4 6 static const String dmAccessToken = 'dm_access_token'; 5 7 static const String dmRefreshToken = 'dm_refresh_token'; ··· 27 29 28 30 /// Post to Bluesky 29 31 static const String postToBskyKey = 'post_to_bsky_enabled'; 30 - 31 - // Private constructor to prevent instantiation 32 - StorageKeys._(); 33 32 }
+5 -7
lib/src/core/storage/preferences/storage_manager.dart
··· 1 - import 'local_storage_interface.dart'; 2 - import 'shared_prefs_storage.dart'; 3 - import 'secure_storage.dart'; 1 + import 'package:sparksocial/src/core/storage/preferences/local_storage_interface.dart'; 2 + import 'package:sparksocial/src/core/storage/preferences/secure_storage.dart'; 3 + import 'package:sparksocial/src/core/storage/preferences/shared_prefs_storage.dart'; 4 4 5 5 /// Storage manager providing centralized access to different storage implementations 6 6 /// This is the one that should be used to store and retrieve data from the app 7 7 class StorageManager { 8 + /// Private constructor 9 + StorageManager._(); 8 10 late final LocalStorageInterface _preferences; 9 11 late final LocalStorageInterface _secureStorage; 10 - 11 - /// Private constructor 12 - StorageManager._(); 13 12 14 13 /// Singleton instance 15 14 static final StorageManager _instance = StorageManager._(); ··· 28 27 29 28 /// Access to secure storage for sensitive data 30 29 LocalStorageInterface get secure => _secureStorage; 31 - 32 30 }
+4 -6
lib/src/core/storage/storage.dart
··· 1 - library; 2 - 3 - export 'preferences/local_storage_interface.dart'; 4 - export 'preferences/storage_manager.dart'; 5 - export 'preferences/storage_constants.dart'; 6 1 export 'cache/cache_manager_interface.dart'; 2 + export 'cache/download_manager_impl.dart'; 7 3 export 'cache/sql_cache_impl.dart'; 8 - export 'cache/download_manager_impl.dart'; 4 + export 'preferences/local_storage_interface.dart'; 5 + export 'preferences/storage_constants.dart'; 6 + export 'preferences/storage_manager.dart';
+3 -6
lib/src/core/theme/data/models/app_theme.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 - import 'colors.dart'; 3 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 5 5 /// Application theme management class that provides theme data 6 6 /// for both light and dark themes. ··· 61 61 primary: AppColors.primary, 62 62 secondary: AppColors.secondary, 63 63 tertiary: AppColors.accent, 64 - surface: AppColors.background, 65 64 error: AppColors.error, 66 - onPrimary: AppColors.white, 67 65 onSecondary: AppColors.white, 68 66 onTertiary: AppColors.white, 69 67 onSurface: AppColors.textPrimary, 70 - onError: AppColors.white, 71 68 ), 72 69 primaryColor: AppColors.primary, 73 70 useMaterial3: true, ··· 129 126 ), 130 127 enabledBorder: OutlineInputBorder( 131 128 borderRadius: BorderRadius.circular(12), 132 - borderSide: const BorderSide(color: AppColors.border, width: 1), 129 + borderSide: const BorderSide(color: AppColors.border), 133 130 ), 134 131 focusedBorder: OutlineInputBorder( 135 132 borderRadius: BorderRadius.circular(12), ··· 250 247 ), 251 248 enabledBorder: OutlineInputBorder( 252 249 borderRadius: BorderRadius.circular(12), 253 - borderSide: const BorderSide(color: AppColors.border, width: 1), 250 + borderSide: const BorderSide(color: AppColors.border), 254 251 ), 255 252 focusedBorder: OutlineInputBorder( 256 253 borderRadius: BorderRadius.circular(12),
+3 -3
lib/src/core/theme/data/models/colors.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 /// Application color constants. 4 - /// 4 + /// 5 5 /// These colors are used throughout the application to ensure consistency 6 6 /// and maintainability. 7 7 class AppColors { ··· 19 19 static const Color deepPurple = Color(0xFF28232D); 20 20 static const Color richPurple = Color(0xFF330072); 21 21 static const Color brightPurple = Color(0xFFB20AFF); 22 - 22 + 23 23 // Core colors 24 24 static const Color pink = Color(0xFFFF2696); // Main app color for buttons and highlights 25 25 static const Color white = Color(0xFFFFFFFF); ··· 70 70 static const Color unselectedIconLight = darkPurple; 71 71 static const Color selectedIconDark = white; 72 72 static const Color unselectedIconDark = darkPurple; 73 - } 73 + }
+2 -3
lib/src/core/theme/data/repositories/theme_repository_impl.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:sparksocial/src/core/storage/storage.dart'; 3 + import 'package:sparksocial/src/core/theme/data/repositories/theme_repository.dart'; 3 4 import 'package:sparksocial/src/core/theme/domain/theme_provider.dart'; 4 - import 'theme_repository.dart'; 5 5 6 6 /// Implementation of ThemeRepository using SharedPreferences 7 7 class ThemeRepositoryImpl implements ThemeRepository { 8 + const ThemeRepositoryImpl(this._storageManager); 8 9 final StorageManager _storageManager; 9 - 10 - const ThemeRepositoryImpl(this._storageManager); 11 10 12 11 @override 13 12 Future<ThemeMode?> getThemeMode() async {
+1 -1
lib/src/core/theme/domain/theme_provider.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:get_it/get_it.dart'; 4 4 import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 - import 'package:sparksocial/src/core/theme/domain/theme_state.dart'; 6 5 import 'package:sparksocial/src/core/theme/data/repositories/theme_repository.dart'; 6 + import 'package:sparksocial/src/core/theme/domain/theme_state.dart'; 7 7 8 8 part 'theme_provider.g.dart'; 9 9
+2 -4
lib/src/core/theme/theme.dart
··· 1 - library; 2 - 3 - export 'data/models/colors.dart'; 4 - export 'domain/theme_state.dart'; 5 1 export 'data/models/app_theme.dart'; 2 + export 'data/models/colors.dart'; 6 3 export 'domain/theme_provider.dart'; 4 + export 'domain/theme_state.dart';
+13 -13
lib/src/core/utils/label_utils.dart
··· 6 6 class LabelUtils { 7 7 static Future<bool> shouldShowWarning(List<Label> labels) async { 8 8 if (labels.isEmpty) return false; 9 - 9 + 10 10 final settingsRepository = GetIt.instance<SettingsRepository>(); 11 - 11 + 12 12 for (final label in labels) { 13 13 try { 14 14 final preference = await settingsRepository.getLabelPreference(label.value); ··· 20 20 continue; 21 21 } 22 22 } 23 - 23 + 24 24 return false; 25 25 } 26 26 27 27 static Future<bool> shouldBlurContent(List<Label> labels) async { 28 28 if (labels.isEmpty) return false; 29 - 29 + 30 30 final settingsRepository = GetIt.instance<SettingsRepository>(); 31 - 31 + 32 32 for (final label in labels) { 33 33 try { 34 34 final preference = await settingsRepository.getLabelPreference(label.value); ··· 43 43 44 44 return false; 45 45 } 46 - 46 + 47 47 static Future<List<String>> getWarningLabels(List<Label> labels) async { 48 48 if (labels.isEmpty) return []; 49 - 49 + 50 50 final settingsRepository = GetIt.instance<SettingsRepository>(); 51 51 final warningLabels = <String>[]; 52 - 52 + 53 53 for (final label in labels) { 54 54 try { 55 55 final preference = await settingsRepository.getLabelPreference(label.value); ··· 61 61 continue; 62 62 } 63 63 } 64 - 64 + 65 65 return warningLabels; 66 66 } 67 67 68 68 static Future<List<String>> getInformLabels(List<Label> labels) async { 69 69 if (labels.isEmpty) return []; 70 - 70 + 71 71 final settingsRepository = GetIt.instance<SettingsRepository>(); 72 72 final informLabels = <String>[]; 73 - 73 + 74 74 for (final label in labels) { 75 75 try { 76 76 final preference = await settingsRepository.getLabelPreference(label.value); ··· 82 82 continue; 83 83 } 84 84 } 85 - 85 + 86 86 return informLabels; 87 87 } 88 - } 88 + }
+6 -6
lib/src/core/utils/logging/console_output.dart
··· 1 1 import 'dart:developer' as developer; 2 2 3 3 import 'package:flutter/foundation.dart'; 4 - import 'log_level.dart'; 5 - import 'log_output.dart'; 4 + import 'package:sparksocial/src/core/utils/logging/log_level.dart'; 5 + import 'package:sparksocial/src/core/utils/logging/log_output.dart'; 6 6 7 7 /// Outputs logs to the console 8 8 class ConsoleOutput implements LogOutput { 9 + /// Constructor 10 + ConsoleOutput({this.useColors = true}); 11 + 9 12 /// Color codes for different log levels 10 13 static const Map<LogLevel, String> _colorCodes = { 11 14 LogLevel.verbose: '\x1B[37m', // White ··· 23 26 /// Whether to use colors in the output 24 27 final bool useColors; 25 28 26 - /// Constructor 27 - ConsoleOutput({this.useColors = true}); 28 - 29 29 @override 30 30 void output(LogLevel level, String message, DateTime timestamp, Object? error, StackTrace? stackTrace) { 31 31 final timeString = _formatTime(timestamp); 32 32 final levelString = '[${level.name}]'.padRight(9); 33 33 final prefix = '$timeString $levelString'; 34 34 35 - String formattedMessage = '$prefix $message'; 35 + var formattedMessage = '$prefix $message'; 36 36 37 37 if (error != null) { 38 38 formattedMessage += '\nError: $error';
+11 -11
lib/src/core/utils/logging/file_output.dart
··· 1 1 // ignore_for_file: avoid_print 2 2 3 3 import 'dart:io'; 4 + 4 5 import 'package:path_provider/path_provider.dart'; 6 + import 'package:sparksocial/src/core/utils/logging/log_level.dart'; 7 + import 'package:sparksocial/src/core/utils/logging/log_output.dart'; 5 8 import 'package:synchronized/synchronized.dart'; 6 9 7 - import 'log_level.dart'; 8 - import 'log_output.dart'; 9 - 10 10 /// Outputs logs to a file 11 11 class FileOutput implements LogOutput { 12 + /// Constructor 13 + FileOutput({ 14 + String fileName = 'spark_app.log', 15 + int maxFileSize = 10 * 1024 * 1024, // 10 MB 16 + }) : _fileName = fileName, 17 + _maxFileSize = maxFileSize; 18 + 12 19 /// The file to write logs to 13 20 File? _file; 14 21 ··· 23 30 24 31 /// Whether the file has been initialized 25 32 bool _initialized = false; 26 - 27 - /// Constructor 28 - FileOutput({ 29 - String fileName = 'spark_app.log', 30 - int maxFileSize = 10 * 1024 * 1024, // 10 MB 31 - }) : _fileName = fileName, 32 - _maxFileSize = maxFileSize; 33 33 34 34 /// Initialize the file output 35 35 Future<void> initialize() async { ··· 103 103 if (_file == null) return false; 104 104 105 105 try { 106 - final fileStats = await _file!.stat(); 106 + final fileStats = _file!.statSync(); 107 107 return fileStats.size > _maxFileSize; 108 108 } catch (e) { 109 109 return false;
+1 -1
lib/src/core/utils/logging/log_output.dart
··· 1 - import 'log_level.dart'; 1 + import 'package:sparksocial/src/core/utils/logging/log_level.dart'; 2 2 3 3 /// Interface for log outputs 4 4 abstract class LogOutput {
+3 -3
lib/src/core/utils/logging/log_service.dart
··· 1 - import 'log_level.dart'; 2 - import 'logger.dart'; 3 - import 'logger_factory.dart'; 1 + import 'package:sparksocial/src/core/utils/logging/log_level.dart'; 2 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 3 + import 'package:sparksocial/src/core/utils/logging/logger_factory.dart'; 4 4 5 5 /// Log service for dependency injection 6 6 class LogService {
+15 -15
lib/src/core/utils/logging/logger.dart
··· 1 - import 'log_level.dart'; 2 - import 'log_output.dart'; 1 + import 'package:sparksocial/src/core/utils/logging/log_level.dart'; 2 + import 'package:sparksocial/src/core/utils/logging/log_output.dart'; 3 3 4 4 /// A flexible and customizable logging system for Spark Social 5 5 class SparkLogger { 6 + /// Constructor 7 + SparkLogger({ 8 + String name = '', 9 + LogLevel minLevel = LogLevel.info, 10 + List<LogOutput> outputs = const [], 11 + bool includeStackTrace = true, 12 + }) : _name = name, 13 + _minLevel = minLevel, 14 + _outputs = List.from(outputs), 15 + _includeStackTrace = includeStackTrace; 16 + 6 17 /// The minimum log level that will be output 7 18 final LogLevel _minLevel; 8 19 ··· 14 25 15 26 /// Whether to include stack traces for errors 16 27 final bool _includeStackTrace; 17 - 18 - /// Constructor 19 - SparkLogger({ 20 - String name = '', 21 - LogLevel minLevel = LogLevel.info, 22 - List<LogOutput> outputs = const [], 23 - bool includeStackTrace = true, 24 - }) : _name = name, 25 - _minLevel = minLevel, 26 - _outputs = List.from(outputs), 27 - _includeStackTrace = includeStackTrace; 28 28 29 29 /// Log a verbose message 30 30 void v(String message, {Object? error, StackTrace? stackTrace}) { ··· 64 64 } 65 65 66 66 // Add name prefix if provided 67 - final String prefixedMessage = _name.isNotEmpty ? '[$_name] $message' : message; 67 + final prefixedMessage = _name.isNotEmpty ? '[$_name] $message' : message; 68 68 69 69 // Get stack trace if requested and not provided 70 - StackTrace? trace = stackTrace; 70 + var trace = stackTrace; 71 71 if (error != null && trace == null && _includeStackTrace) { 72 72 trace = StackTrace.current; 73 73 }
+5 -5
lib/src/core/utils/logging/logger_factory.dart
··· 1 1 import 'package:flutter/foundation.dart'; 2 2 3 - import 'console_output.dart'; 4 - import 'file_output.dart'; 5 - import 'log_level.dart'; 6 - import 'log_output.dart'; 7 - import 'logger.dart'; 3 + import 'package:sparksocial/src/core/utils/logging/console_output.dart'; 4 + import 'package:sparksocial/src/core/utils/logging/file_output.dart'; 5 + import 'package:sparksocial/src/core/utils/logging/log_level.dart'; 6 + import 'package:sparksocial/src/core/utils/logging/log_output.dart'; 7 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 8 8 9 9 /// Factory for creating logger instances 10 10 class LoggerFactory {
+34 -32
lib/src/core/utils/logging/riverpod_logger.dart
··· 1 1 import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 2 import 'package:get_it/get_it.dart'; 3 3 4 - import 'logging.dart'; 4 + import 'package:sparksocial/src/core/utils/logging/logging.dart'; 5 5 6 6 /// A ProviderObserver that logs provider changes 7 7 class SparkRiverpodLogger extends ProviderObserver { 8 - final LogService _logService; 9 - late final SparkLogger _logger; 10 - 11 8 /// Constructor 12 9 SparkRiverpodLogger({LogService? logService}) : _logService = logService ?? GetIt.instance<LogService>() { 13 10 _logger = _logService.getLogger('Riverpod'); 14 11 } 12 + final LogService _logService; 13 + late final SparkLogger _logger; 15 14 16 15 @override 17 16 void didAddProvider(ProviderBase<Object?> provider, Object? value, ProviderContainer container) { ··· 98 97 /// Generates diff for List objects 99 98 String _generateListDiff(List previous, List current) { 100 99 final changes = <String>[]; 101 - 100 + 102 101 // Find added items (items in current but not in previous) 103 102 final added = <dynamic>[]; 104 103 for (final item in current) { ··· 106 105 added.add(item); 107 106 } 108 107 } 109 - 108 + 110 109 // Find removed items (items in previous but not in current) 111 110 final removed = <dynamic>[]; 112 111 for (final item in previous) { ··· 114 113 removed.add(item); 115 114 } 116 115 } 117 - 116 + 118 117 // Add length change if different 119 118 if (previous.length != current.length) { 120 119 changes.add('length: ${previous.length} → ${current.length}'); 121 120 } 122 - 121 + 123 122 // Add removed items 124 123 if (removed.isNotEmpty) { 125 124 if (removed.length <= 3) { ··· 128 127 changes.add('removed: ${removed.length} items'); 129 128 } 130 129 } 131 - 130 + 132 131 // Add added items 133 132 if (added.isNotEmpty) { 134 133 if (added.length <= 3) { ··· 137 136 changes.add('added: ${added.length} items'); 138 137 } 139 138 } 140 - 139 + 141 140 // If no adds/removes but lists are different, check for positional changes 142 141 if (changes.isEmpty && previous.length == current.length) { 143 - for (int i = 0; i < previous.length; i++) { 142 + for (var i = 0; i < previous.length; i++) { 144 143 if (previous[i] != current[i]) { 145 - changes.add('[$i]: ${_truncateIfNeeded(previous[i].toString(), maxLength: 50)} → ${_truncateIfNeeded(current[i].toString(), maxLength: 50)}'); 144 + changes.add( 145 + '[$i]: ${_truncateIfNeeded(previous[i].toString(), maxLength: 50)} → ${_truncateIfNeeded(current[i].toString(), maxLength: 50)}', 146 + ); 146 147 if (changes.length >= 3) { 147 148 changes.add('... and ${previous.length - i - 1} more changes'); 148 149 break; ··· 154 155 return changes.isEmpty ? 'No changes' : changes.join(', '); 155 156 } 156 157 157 - /// Tries to parse structured objects like ClassName(field1: value1, field2: value2) 158 + /// Tries to parse structured objects like ClassName(field1: value1, field2: value2) 158 159 String? _tryParseStructuredObjectDiff(String previous, String current) { 159 160 final prevFields = _parseStructuredObject(previous); 160 161 final currFields = _parseStructuredObject(current); 161 - 162 + 162 163 if (prevFields == null || currFields == null) return null; 163 - 164 + 164 165 final changes = <String>[]; 165 166 final allKeys = {...prevFields.keys, ...currFields.keys}; 166 167 ··· 182 183 if (listDiff != null) { 183 184 changes.add('$key: $listDiff'); 184 185 } else { 185 - changes.add('$key: ${_truncateIfNeeded(prevValue, maxLength: 100)} → ${_truncateIfNeeded(currValue, maxLength: 100)}'); 186 + changes.add( 187 + '$key: ${_truncateIfNeeded(prevValue, maxLength: 100)} → ${_truncateIfNeeded(currValue, maxLength: 100)}', 188 + ); 186 189 } 187 190 } 188 191 } ··· 222 225 return fields.isEmpty ? null : fields; 223 226 } 224 227 225 - /// Splits field strings by comma, handling nested structures 228 + /// Splits field strings by comma, handling nested structures 226 229 List<String> _splitFields(String content) { 227 230 final parts = <String>[]; 228 231 final buffer = StringBuffer(); 229 - int depth = 0; 230 - bool inString = false; 232 + var depth = 0; 233 + var inString = false; 231 234 String? currentQuote; 232 - 233 - for (int i = 0; i < content.length; i++) { 235 + 236 + for (var i = 0; i < content.length; i++) { 234 237 final char = content[i]; 235 - 238 + 236 239 // Handle string literals 237 240 if (!inString && (char == '"' || char == "'")) { 238 241 inString = true; 239 242 currentQuote = char; 240 243 } else if (inString && char == currentQuote) { 241 244 // Check if it's not escaped 242 - if (i == 0 || content[i - 1] != '\\') { 245 + if (i == 0 || content[i - 1] != r'\') { 243 246 inString = false; 244 247 currentQuote = null; 245 248 } ··· 259 262 continue; 260 263 } 261 264 } 262 - 265 + 263 266 buffer.write(char); 264 267 } 265 - 268 + 266 269 // Add the last part 267 270 final lastPart = buffer.toString().trim(); 268 271 if (lastPart.isNotEmpty) { 269 272 parts.add(lastPart); 270 273 } 271 - 274 + 272 275 return parts; 273 276 } 274 277 275 278 /// Tries to parse field values as lists and generate list diffs 276 279 String? _tryParseFieldAsListDiff(String prevValue, String currValue) { 277 280 // Check if both values look like lists [...] 278 - if (!prevValue.startsWith('[') || !prevValue.endsWith(']') || 279 - !currValue.startsWith('[') || !currValue.endsWith(']')) { 281 + if (!prevValue.startsWith('[') || !prevValue.endsWith(']') || !currValue.startsWith('[') || !currValue.endsWith(']')) { 280 282 return null; 281 283 } 282 284 283 285 // Parse the list contents 284 286 final prevList = _parseListString(prevValue); 285 287 final currList = _parseListString(currValue); 286 - 288 + 287 289 if (prevList == null || currList == null) return null; 288 - 290 + 289 291 return _generateListDiff(prevList, currList); 290 292 } 291 293 292 294 /// Parses a list string like "[item1, item2, item3]" into a List 293 295 List<String>? _parseListString(String listStr) { 294 296 if (!listStr.startsWith('[') || !listStr.endsWith(']')) return null; 295 - 297 + 296 298 final content = listStr.substring(1, listStr.length - 1).trim(); 297 299 if (content.isEmpty) return <String>[]; 298 - 300 + 299 301 // Split by comma, handling nested structures 300 302 return _splitFields(content); 301 303 }
+5 -6
lib/src/core/utils/text_formatter.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import '../theme/data/models/colors.dart'; 3 - import '../widgets/mentioned_text.dart'; 2 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 + import 'package:sparksocial/src/core/widgets/mentioned_text.dart'; 4 4 5 5 /// Utility class for text formatting and processing 6 6 class TextFormatter { ··· 30 30 31 31 /// Finds username matches in a text string 32 32 static List<Match> findUsernameMatches(String text) { 33 - final RegExp usernameRegex = RegExp(r'@([a-zA-Z0-9_.-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9_]+)', caseSensitive: false); 33 + final usernameRegex = RegExp(r'@([a-zA-Z0-9_.-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9_]+)', caseSensitive: false); 34 34 35 35 return usernameRegex.allMatches(text).toList(); 36 36 } 37 37 38 38 /// Extracts URLs from a text string 39 39 static List<String> extractUrls(String text) { 40 - final RegExp urlRegex = RegExp( 40 + final urlRegex = RegExp( 41 41 r'(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})', 42 42 caseSensitive: false, 43 43 ); 44 44 45 - final List<String> urls = []; 45 + final urls = <String>[]; 46 46 for (final Match match in urlRegex.allMatches(text)) { 47 47 final url = match.group(0)!; 48 48 if (url.startsWith('@')) continue; ··· 78 78 onUsernameTap: onUsernameTap, 79 79 expandText: expandDescription, 80 80 maxLines: expandDescription ? null : 2, 81 - overflow: TextOverflow.ellipsis, 82 81 textStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 14), 83 82 mentionStyle: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold), 84 83 );
+1 -3
lib/src/core/utils/utils.dart
··· 1 - library; 2 - 3 - export 'text_formatter.dart'; 4 1 export 'logging/logging.dart'; 2 + export 'text_formatter.dart';
+10 -11
lib/src/core/widgets/action_button.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 class ActionButton extends StatelessWidget { 4 - final IconData icon; 5 - final String label; 6 - final VoidCallback? onPressed; 7 - final Color? color; 8 - final bool isAnimating; 9 - final double scale; 10 - final bool showLabel; 11 - 12 4 const ActionButton({ 13 - super.key, 14 5 required this.icon, 15 6 required this.label, 7 + super.key, 16 8 this.onPressed, 17 9 this.color, 18 10 this.isAnimating = false, 19 11 this.scale = 1.0, 20 12 this.showLabel = true, 21 13 }); 14 + final IconData icon; 15 + final String label; 16 + final VoidCallback? onPressed; 17 + final Color? color; 18 + final bool isAnimating; 19 + final double scale; 20 + final bool showLabel; 22 21 23 22 @override 24 23 Widget build(BuildContext context) { ··· 67 66 color: Colors.white, 68 67 fontSize: 12, 69 68 shadows: <Shadow>[ 70 - Shadow(offset: Offset(0, 0), blurRadius: 20.0, color: Color(0xFF000000)), 71 - Shadow(offset: Offset(1, 1), blurRadius: 8.0, color: Colors.black87), 69 + Shadow(blurRadius: 20), 70 + Shadow(offset: Offset(1, 1), blurRadius: 8, color: Colors.black87), 72 71 ], 73 72 ), 74 73 ),
+3 -5
lib/src/core/widgets/alt_text_editor_dialog.dart
··· 6 6 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 7 8 8 class AltTextEditorDialog extends StatefulWidget { 9 + const AltTextEditorDialog({required this.initialAltText, super.key, this.imageFile}); 9 10 final XFile? imageFile; 10 11 final String initialAltText; 11 - 12 - const AltTextEditorDialog({super.key, this.imageFile, required this.initialAltText}); 13 12 14 13 @override 15 14 State<AltTextEditorDialog> createState() => _AltTextEditorDialogState(); ··· 45 44 insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), 46 45 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 47 46 child: Padding( 48 - padding: const EdgeInsets.all(16.0), 47 + padding: const EdgeInsets.all(16), 49 48 child: SingleChildScrollView( 50 49 child: Column( 51 50 mainAxisSize: MainAxisSize.min, 52 - crossAxisAlignment: CrossAxisAlignment.center, 53 51 children: [ 54 52 if (widget.imageFile != null) 55 53 ClipRRect( ··· 63 61 decoration: BoxDecoration( 64 62 color: inputBackgroundColor, 65 63 borderRadius: BorderRadius.circular(10), 66 - border: Border.all(color: borderColor, width: 1), 64 + border: Border.all(color: borderColor), 67 65 ), 68 66 child: TextField( 69 67 controller: _controller,
+3 -3
lib/src/core/widgets/content_warning_overlay.dart
··· 5 5 6 6 class ContentWarningOverlay extends StatelessWidget { 7 7 const ContentWarningOverlay({ 8 - super.key, 9 8 required this.onViewContent, 10 9 required this.warningLabels, 10 + super.key, 11 11 this.shouldBlur = false, 12 12 this.child, 13 13 }); ··· 22 22 children: [ 23 23 // Original content (blurred/hidden) 24 24 if (child != null) 25 - if (shouldBlur) ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 40.0, sigmaY: 40.0), child: child!) else child!, 25 + if (shouldBlur) ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), child: child) else child!, 26 26 // Warning overlay 27 27 Positioned.fill( 28 28 child: Center( 29 29 child: Padding( 30 - padding: const EdgeInsets.all(24.0), 30 + padding: const EdgeInsets.all(24), 31 31 child: Column( 32 32 mainAxisAlignment: MainAxisAlignment.center, 33 33 children: [
+7 -8
lib/src/core/widgets/custom_text_field.dart
··· 2 2 3 3 /// A custom styled text field with optional undo functionality. 4 4 class CustomTextField extends StatelessWidget { 5 - final TextEditingController controller; 6 - final String hintText; 7 - final Color? fillColor; 8 - final int maxLines; 9 - final VoidCallback? onUndo; 10 - final String? Function(String?)? validator; 11 - 12 5 const CustomTextField({ 13 - super.key, 14 6 required this.controller, 15 7 required this.hintText, 8 + super.key, 16 9 this.fillColor, 17 10 this.maxLines = 1, 18 11 this.onUndo, 19 12 this.validator, 20 13 }); 14 + final TextEditingController controller; 15 + final String hintText; 16 + final Color? fillColor; 17 + final int maxLines; 18 + final VoidCallback? onUndo; 19 + final String? Function(String?)? validator; 21 20 22 21 @override 23 22 Widget build(BuildContext context) {
+12 -14
lib/src/core/widgets/fading_list_view.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 - import '../theme/data/models/colors.dart'; 3 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 5 5 /// A list view with fading edges for a smooth scrolling experience 6 6 class FadingListView extends StatelessWidget { 7 - final List<Widget> children; 8 - final bool isHorizontal; 9 - final EdgeInsetsGeometry? padding; 10 - final double itemSpacing; 11 - final double fadeWidth; 12 - final ScrollController? controller; 13 - final MainAxisAlignment mainAxisAlignment; 14 - final CrossAxisAlignment crossAxisAlignment; 15 - 16 7 const FadingListView({ 8 + required this.children, 17 9 super.key, 18 - required this.children, 19 10 this.isHorizontal = true, 20 11 this.padding, 21 12 this.itemSpacing = 8.0, ··· 24 15 this.mainAxisAlignment = MainAxisAlignment.start, 25 16 this.crossAxisAlignment = CrossAxisAlignment.center, 26 17 }); 18 + final List<Widget> children; 19 + final bool isHorizontal; 20 + final EdgeInsetsGeometry? padding; 21 + final double itemSpacing; 22 + final double fadeWidth; 23 + final ScrollController? controller; 24 + final MainAxisAlignment mainAxisAlignment; 25 + final CrossAxisAlignment crossAxisAlignment; 27 26 28 27 @override 29 28 Widget build(BuildContext context) { ··· 32 31 } 33 32 34 33 // Add spacing between items 35 - final List<Widget> itemsWithSpacing = []; 36 - for (int i = 0; i < children.length; i++) { 34 + final itemsWithSpacing = <Widget>[]; 35 + for (var i = 0; i < children.length; i++) { 37 36 itemsWithSpacing.add(children[i]); 38 37 if (i < children.length - 1) { 39 38 itemsWithSpacing.add(isHorizontal ? SizedBox(width: itemSpacing) : SizedBox(height: itemSpacing)); ··· 50 49 ) 51 50 : SingleChildScrollView( 52 51 controller: controller, 53 - scrollDirection: Axis.vertical, 54 52 padding: EdgeInsets.symmetric(vertical: fadeWidth).add(padding ?? EdgeInsets.zero), 55 53 child: Column( 56 54 mainAxisAlignment: mainAxisAlignment,
+10 -11
lib/src/core/widgets/heart_animation.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 class HeartAnimation extends StatefulWidget { 4 - final bool isAnimating; 5 - final Duration duration; 6 - final VoidCallback? onEnd; 7 - final Widget child; 8 - 9 4 const HeartAnimation({ 5 + required this.isAnimating, 6 + required this.child, 10 7 super.key, 11 - required this.isAnimating, 12 8 this.duration = const Duration(milliseconds: 1000), 13 9 this.onEnd, 14 - required this.child, 15 10 }); 11 + final bool isAnimating; 12 + final Duration duration; 13 + final VoidCallback? onEnd; 14 + final Widget child; 16 15 17 16 @override 18 17 State<HeartAnimation> createState() => _HeartAnimationState(); ··· 28 27 super.initState(); 29 28 _controller = AnimationController(duration: widget.duration, vsync: this); 30 29 31 - _scaleAnimation = Tween<double>(begin: 0.0, end: 1.4).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); 30 + _scaleAnimation = Tween<double>(begin: 0, end: 1.4).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); 32 31 33 - _opacityAnimation = Tween<double>(begin: 0.8, end: 0.0).animate( 32 + _opacityAnimation = Tween<double>(begin: 0.8, end: 0).animate( 34 33 CurvedAnimation( 35 34 parent: _controller, 36 - curve: const Interval(0.5, 1.0, curve: Curves.easeOut), 35 + curve: const Interval(0.5, 1, curve: Curves.easeOut), 37 36 ), 38 37 ); 39 38 ··· 80 79 Icons.favorite, 81 80 color: Colors.red, 82 81 size: 100, 83 - shadows: [Shadow(offset: Offset(0, 0), blurRadius: 10, color: Colors.red)], 82 + shadows: [Shadow(blurRadius: 10, color: Colors.red)], 84 83 ), 85 84 ), 86 85 );
+2 -2
lib/src/core/widgets/image_content.dart
··· 6 6 import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 7 7 8 8 class ImageContent extends StatelessWidget { 9 - const ImageContent({super.key, required this.imageUrls, required this.borderRadius, this.thumbnailSize = 100}); 9 + const ImageContent({required this.imageUrls, required this.borderRadius, super.key, this.thumbnailSize = 100}); 10 10 final List<String> imageUrls; 11 11 final BorderRadius borderRadius; 12 12 final double thumbnailSize; ··· 63 63 child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)), 64 64 ), 65 65 ), 66 - errorWidget: (context, url, error) => Container( 66 + errorWidget: (context, url, error) => ColoredBox( 67 67 color: AppColors.darkPurple.withValues(alpha: 26), 68 68 child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 24, color: Colors.white70)), 69 69 ),
+18 -18
lib/src/core/widgets/mentioned_text.dart
··· 1 + import 'package:flutter/gestures.dart'; 1 2 import 'package:flutter/material.dart'; 2 - import 'package:flutter/gestures.dart'; 3 - import '../theme/data/models/colors.dart'; 4 - import '../utils/text_formatter.dart'; 3 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 + import 'package:sparksocial/src/core/utils/text_formatter.dart'; 5 5 6 6 /// A widget that displays text with clickable @mentions 7 7 class MentionedText extends StatelessWidget { 8 + /// Creates a text widget with clickable @mentions 9 + const MentionedText({ 10 + required this.text, 11 + required this.onUsernameTap, 12 + super.key, 13 + this.expandText = false, 14 + this.maxLines = 2, 15 + this.overflow = TextOverflow.ellipsis, 16 + this.textStyle, 17 + this.mentionStyle, 18 + }); 19 + 8 20 /// The text to display, which may contain @mentions 9 21 final String text; 10 22 ··· 26 38 /// Text style to apply to the @mentions, merged with textStyle 27 39 final TextStyle? mentionStyle; 28 40 29 - /// Creates a text widget with clickable @mentions 30 - const MentionedText({ 31 - super.key, 32 - required this.text, 33 - required this.onUsernameTap, 34 - this.expandText = false, 35 - this.maxLines = 2, 36 - this.overflow = TextOverflow.ellipsis, 37 - this.textStyle, 38 - this.mentionStyle, 39 - }); 40 - 41 41 @override 42 42 Widget build(BuildContext context) { 43 43 final usernameMatches = TextFormatter.findUsernameMatches(text); ··· 49 49 final effectiveMentionStyle = mentionStyle ?? const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold); 50 50 51 51 // Build text spans for mentions 52 - final List<InlineSpan> spans = []; 53 - int lastEnd = 0; 52 + final spans = <InlineSpan>[]; 53 + var lastEnd = 0; 54 54 55 55 usernameMatches.sort((a, b) => a.start.compareTo(b.start)); 56 56 ··· 75 75 spans.add(TextSpan(text: text.substring(lastEnd), style: baseStyle)); 76 76 } 77 77 78 - final TextSpan textSpan = TextSpan(children: spans, style: baseStyle); 78 + final textSpan = TextSpan(children: spans, style: baseStyle); 79 79 80 80 return RichText( 81 81 text: textSpan,
+15 -17
lib/src/core/widgets/menu_action_button.dart
··· 6 6 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 7 8 8 class MenuActionButton extends StatelessWidget { 9 - final VoidCallback? onPressed; 10 - final VoidCallback? onDeletePressed; 11 - final bool isCompact; 12 - final Color? backgroundColor; 13 - final bool isProfile; 14 - final bool isOnVideo; 15 - final bool isOwnPost; 16 - final String? authorDid; 17 - 18 9 const MenuActionButton({ 19 10 super.key, 20 11 this.onPressed, ··· 26 17 this.isOwnPost = false, 27 18 this.authorDid, 28 19 }); 20 + final VoidCallback? onPressed; 21 + final VoidCallback? onDeletePressed; 22 + final bool isCompact; 23 + final Color? backgroundColor; 24 + final bool isProfile; 25 + final bool isOnVideo; 26 + final bool isOwnPost; 27 + final String? authorDid; 29 28 30 29 void _showOptionsMenu(BuildContext context) { 31 30 final theme = Theme.of(context); ··· 51 50 if (isCurrentUserAuthor) 52 51 ListTile( 53 52 leading: const Icon(Icons.delete_outline, color: Colors.red), 54 - title: Text('Delete', style: TextStyle(color: Colors.red)), 53 + title: const Text('Delete', style: TextStyle(color: Colors.red)), 55 54 onTap: () async { 56 55 await context.router.maybePop(); 57 56 if (onDeletePressed != null) { ··· 104 103 } 105 104 106 105 class CompactMenuButton extends StatelessWidget { 107 - final VoidCallback? onPressed; 108 - final VoidCallback? onDeletePressed; 109 - final Color? backgroundColor; 110 - final bool isProfile; 111 - final bool isOnVideo; 112 - final String? authorDid; 113 - 114 106 const CompactMenuButton({ 115 107 super.key, 116 108 this.onPressed, ··· 120 112 this.isOnVideo = false, 121 113 this.authorDid, 122 114 }); 115 + final VoidCallback? onPressed; 116 + final VoidCallback? onDeletePressed; 117 + final Color? backgroundColor; 118 + final bool isProfile; 119 + final bool isOnVideo; 120 + final String? authorDid; 123 121 124 122 @override 125 123 Widget build(BuildContext context) {
+9 -10
lib/src/core/widgets/report_dialog.dart
··· 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 8 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 9 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 9 10 10 11 class ReportDialog extends ConsumerStatefulWidget { 12 + const ReportDialog({required this.postUri, required this.postCid, super.key, this.onSubmit}); 11 13 final String postUri; 12 14 final String postCid; 13 15 final Function(ReportSubject subject, ModerationReasonType reasonType, String? reason, ModerationService? service)? onSubmit; 14 16 15 - const ReportDialog({super.key, required this.postUri, required this.postCid, this.onSubmit}); 16 - 17 17 @override 18 18 ConsumerState<ReportDialog> createState() => _ReportDialogState(); 19 19 } 20 20 21 21 class _ReportDialogState extends ConsumerState<ReportDialog> { 22 - final _logger = GetIt.instance<LogService>().getLogger('ReportDialog'); 22 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('ReportDialog'); 23 23 ModerationReasonType _selectedReason = ModerationReasonType.spam; 24 24 final TextEditingController _additionalInfoController = TextEditingController(); 25 25 bool _isSubmitting = false; ··· 131 131 132 132 if (_errorMessage != null) 133 133 Padding( 134 - padding: const EdgeInsets.only(top: 8.0), 134 + padding: const EdgeInsets.only(top: 8), 135 135 child: Container( 136 136 padding: const EdgeInsets.all(8), 137 137 decoration: BoxDecoration( ··· 166 166 } 167 167 168 168 class _ReasonTile extends StatelessWidget { 169 - final ModerationReasonType reason; 170 - final ModerationReasonType selectedReason; 171 - final Map<String, String> reasonDescription; 172 - final ValueChanged<ModerationReasonType?> onChanged; 173 - 174 169 const _ReasonTile({ 175 170 required this.reason, 176 171 required this.selectedReason, 177 172 required this.reasonDescription, 178 173 required this.onChanged, 179 174 }); 175 + final ModerationReasonType reason; 176 + final ModerationReasonType selectedReason; 177 + final Map<String, String> reasonDescription; 178 + final ValueChanged<ModerationReasonType?> onChanged; 180 179 181 180 @override 182 181 Widget build(BuildContext context) { ··· 194 193 value: reason, 195 194 groupValue: selectedReason, 196 195 activeColor: theme.colorScheme.primary, 197 - contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), 196 + contentPadding: const EdgeInsets.symmetric(horizontal: 4), 198 197 dense: true, 199 198 visualDensity: const VisualDensity(horizontal: -4, vertical: -4), 200 199 onChanged: onChanged,
+9 -10
lib/src/core/widgets/user_avatar.dart
··· 4 4 5 5 /// A customizable user avatar with fallback options when no image is available 6 6 class UserAvatar extends StatelessWidget { 7 - final String? imageUrl; 8 - final String username; 9 - final double size; 10 - final Color? borderColor; 11 - final double borderWidth; 12 - final Color? backgroundColor; 13 - final Color? fallbackTextColor; 14 - 15 7 const UserAvatar({ 16 8 super.key, 17 9 this.imageUrl, ··· 22 14 this.backgroundColor, 23 15 this.fallbackTextColor, 24 16 }); 17 + final String? imageUrl; 18 + final String username; 19 + final double size; 20 + final Color? borderColor; 21 + final double borderWidth; 22 + final Color? backgroundColor; 23 + final Color? fallbackTextColor; 25 24 26 25 @override 27 26 Widget build(BuildContext context) { ··· 65 64 child: CachedNetworkImage( 66 65 imageUrl: imageUrl!, 67 66 fit: BoxFit.cover, 68 - placeholder: (context, url) => Container( 67 + placeholder: (context, url) => ColoredBox( 69 68 color: effectiveBackgroundColor, 70 69 child: Center( 71 70 child: username.isNotEmpty ··· 76 75 : Icon(FluentIcons.person_24_regular, size: size * 0.5, color: effectiveFallbackTextColor), 77 76 ), 78 77 ), 79 - errorWidget: (context, url, error) => Container( 78 + errorWidget: (context, url, error) => ColoredBox( 80 79 color: effectiveBackgroundColor, 81 80 child: Center( 82 81 child: username.isNotEmpty
+1 -2
lib/src/core/widgets/video_content.dart
··· 4 4 import 'package:video_player/video_player.dart'; 5 5 6 6 class VideoContent extends StatefulWidget { 7 - const VideoContent({super.key, required this.borderRadius, required this.videoUrl}); 7 + const VideoContent({required this.borderRadius, required this.videoUrl, super.key}); 8 8 final BorderRadius borderRadius; 9 9 final String videoUrl; 10 10 ··· 13 13 } 14 14 15 15 class _VideoContentState extends State<VideoContent> { 16 - 17 16 VideoPlayerController? videoController; 18 17 19 18 @override
+1 -4
lib/src/features/auth/auth.dart
··· 1 - library; 2 - 3 1 // Models 4 - export 'providers/auth_state.dart'; 5 2 export '../../core/auth/data/models/identity_info.dart'; 6 3 export '../../core/auth/data/models/login_result.dart'; 7 4 export '../../core/auth/data/models/login_status.dart'; 8 - 9 5 // Repositories 10 6 export '../../core/auth/data/repositories/auth_repository.dart'; 11 7 export '../../core/auth/data/repositories/identity_repository.dart'; 12 8 export '../../core/auth/data/repositories/identity_repository_impl.dart'; 9 + export 'providers/auth_state.dart';
+7 -9
lib/src/features/auth/providers/auth_providers.dart
··· 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 4 import 'package:get_it/get_it.dart'; 5 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 - import 'package:sparksocial/src/features/auth/providers/auth_state.dart'; 7 6 import 'package:sparksocial/src/core/auth/data/models/login_result.dart'; 8 7 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 9 8 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 9 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 10 + import 'package:sparksocial/src/features/auth/providers/auth_state.dart'; 11 11 12 12 part 'auth_providers.g.dart'; 13 13 ··· 33 33 isAuthenticated: _authRepository.isAuthenticated, 34 34 session: _authRepository.session, 35 35 atproto: _authRepository.atproto, 36 - isLoading: false, 37 - error: null, 38 36 ); 39 37 } 40 38 ··· 74 72 return result; 75 73 } catch (e, stackTrace) { 76 74 _logger.e('Login error', error: e, stackTrace: stackTrace); 77 - state = state.copyWith(isLoading: false, error: 'Login failed: ${e.toString()}'); 78 - return LoginResult.failed('Login failed: ${e.toString()}'); 75 + state = state.copyWith(isLoading: false, error: 'Login failed: $e'); 76 + return LoginResult.failed('Login failed: $e'); 79 77 } 80 78 } 81 79 ··· 97 95 return LoginResult.success(); 98 96 } catch (e, stackTrace) { 99 97 _logger.e('Registration error', error: e, stackTrace: stackTrace); 100 - final errorMsg = 'Registration failed: ${e.toString()}'; 98 + final errorMsg = 'Registration failed: $e'; 101 99 state = state.copyWith(isLoading: false, error: errorMsg); 102 100 return LoginResult.failed(errorMsg); 103 101 } ··· 114 112 state = state.copyWith(isLoading: false); 115 113 } catch (e, stackTrace) { 116 114 _logger.e('Logout error', error: e, stackTrace: stackTrace); 117 - state = state.copyWith(isLoading: false, error: 'Logout failed: ${e.toString()}'); 115 + state = state.copyWith(isLoading: false, error: 'Logout failed: $e'); 118 116 } 119 117 } 120 118 ··· 129 127 return result; 130 128 } catch (e, stackTrace) { 131 129 _logger.e('Session validation error', error: e, stackTrace: stackTrace); 132 - state = state.copyWith(error: 'Session validation failed: ${e.toString()}'); 130 + state = state.copyWith(error: 'Session validation failed: $e'); 133 131 return false; 134 132 } 135 133 } ··· 145 143 return result; 146 144 } catch (e, stackTrace) { 147 145 _logger.e('Token refresh error', error: e, stackTrace: stackTrace); 148 - state = state.copyWith(error: 'Token refresh failed: ${e.toString()}'); 146 + state = state.copyWith(error: 'Token refresh failed: $e'); 149 147 return false; 150 148 } 151 149 }
+5 -8
lib/src/features/auth/providers/onboarding_notifier.dart
··· 1 1 import 'dart:typed_data'; 2 + 3 + import 'package:get_it/get_it.dart'; 2 4 import 'package:image_picker/image_picker.dart'; 3 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 - import 'package:get_it/get_it.dart'; 5 - 6 6 import 'package:sparksocial/src/core/auth/data/models/onboarding_screen_state.dart'; 7 7 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 8 8 import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; ··· 30 30 try { 31 31 final session = _authRepository.session; 32 32 if (session == null || session.did.isEmpty) { 33 - _logger.e("User not authenticated or DID is missing."); 33 + _logger.e('User not authenticated or DID is missing.'); 34 34 return const OnboardingScreenState( 35 35 isLoading: false, 36 - errorMessage: "User not authenticated", 37 - displayName: '', 38 - description: '', 36 + errorMessage: 'User not authenticated', 39 37 ); 40 38 } 41 39 final userDid = session.did; ··· 54 52 description: profileDataMap?.description ?? '', 55 53 initialAvatarCid: avatarCid, 56 54 initialAvatarUrl: avatarUrl, 57 - localAvatarBytes: null, 58 55 userDid: userDid, 59 56 ); 60 57 } catch (e, s) { 61 58 _logger.e('Failed to load Bsky profile', error: e, stackTrace: s); 62 - return OnboardingScreenState(isLoading: false, errorMessage: "Failed to load profile.", displayName: '', description: ''); 59 + return const OnboardingScreenState(isLoading: false, errorMessage: 'Failed to load profile.'); 63 60 } 64 61 } 65 62
+5 -5
lib/src/features/auth/providers/onboarding_providers.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:get_it/get_it.dart'; 4 4 import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 6 + import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; 7 + import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; 5 8 import 'package:sparksocial/src/core/network/atproto/data/models/graph_models.dart'; 6 - import '../../../core/network/atproto/data/repositories/sprk_repository.dart'; 7 - import '../../../core/auth/data/repositories/auth_repository.dart'; 8 - import '../../../core/auth/data/repositories/onboarding_repository_impl.dart'; 9 - import '../../../core/auth/data/repositories/onboarding_repository.dart'; 9 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 10 10 11 11 part 'onboarding_providers.g.dart'; 12 12 ··· 94 94 try { 95 95 final repository = ref.read(onboardingRepositoryProvider); 96 96 String? cursor; 97 - bool hasMore = true; 97 + var hasMore = true; 98 98 99 99 // Loop to get all follows with pagination 100 100 while (hasMore) {
+4 -5
lib/src/features/auth/ui/pages/auth_prompt_page.dart
··· 8 8 9 9 @RoutePage() 10 10 class AuthPromptPage extends StatelessWidget { 11 + const AuthPromptPage({super.key, this.onClose}); 11 12 final VoidCallback? onClose; 12 - 13 - const AuthPromptPage({super.key, this.onClose}); 14 13 15 14 @override 16 15 Widget build(BuildContext context) { ··· 36 35 SafeArea( 37 36 child: Center( 38 37 child: Padding( 39 - padding: const EdgeInsets.all(24.0), 38 + padding: const EdgeInsets.all(24), 40 39 child: Column( 41 40 mainAxisAlignment: MainAxisAlignment.center, 42 41 children: [ ··· 46 45 width: 140, 47 46 ), 48 47 const SizedBox(height: 21), 49 - Text( 48 + const Text( 50 49 'Welcome to Spark', 51 50 style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: AppColors.white), 52 51 textAlign: TextAlign.center, 53 52 ), 54 53 const SizedBox(height: 5), 55 - SizedBox( 54 + const SizedBox( 56 55 width: 340, 57 56 child: Text( 58 57 'Add an account to create videos, connect with friends, and more.',
+11 -12
lib/src/features/auth/ui/pages/login_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 1 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter/services.dart'; 4 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 6 import 'package:flutter_svg/flutter_svg.dart'; 6 - import 'package:auto_route/auto_route.dart'; 7 7 import 'package:sparksocial/src/core/routing/app_router.dart'; 8 - 9 8 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 9 + import 'package:sparksocial/src/core/utils/uppercase_text_formatter.dart'; 10 10 import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 11 11 import 'package:sparksocial/src/features/auth/providers/onboarding_providers.dart'; 12 12 import 'package:sparksocial/src/features/auth/ui/widgets/at_account_dialog.dart'; 13 - import 'package:sparksocial/src/core/utils/uppercase_text_formatter.dart'; 14 13 15 14 @RoutePage() 16 15 class LoginPage extends ConsumerStatefulWidget { ··· 64 63 if (!mounted) return; 65 64 66 65 if (result.isSuccess) { 67 - TextInput.finishAutofillContext(shouldSave: true); 66 + TextInput.finishAutofillContext(); 68 67 final hasSparkProfile = await ref.read(onboardingRepositoryProvider).hasSparkProfile(); 69 68 70 69 if (!mounted) return; ··· 99 98 SafeArea( 100 99 child: Center( 101 100 child: SingleChildScrollView( 102 - padding: const EdgeInsets.all(24.0), 101 + padding: const EdgeInsets.all(24), 103 102 child: Form( 104 103 key: _formKey, 105 104 autovalidateMode: AutovalidateMode.onUserInteraction, ··· 113 112 width: 140, 114 113 ), 115 114 const SizedBox(height: 21), 116 - Text( 115 + const Text( 117 116 'Login to your account', 118 117 style: TextStyle(color: AppColors.white, fontSize: 26, fontWeight: FontWeight.bold), 119 118 textAlign: TextAlign.center, ··· 125 124 alignment: WrapAlignment.center, 126 125 crossAxisAlignment: WrapCrossAlignment.center, 127 126 children: [ 128 - Text( 127 + const Text( 129 128 'Login using your existing ', 130 129 style: TextStyle(color: AppColors.white, fontSize: 20, height: 1.7), 131 130 ), ··· 145 144 focusNode: _handleFocusNode, 146 145 decoration: InputDecoration( 147 146 hintText: 'Handle', 148 - hintStyle: TextStyle(color: AppColors.hintText), 147 + hintStyle: const TextStyle(color: AppColors.hintText), 149 148 prefixIcon: const Icon(FluentIcons.person_24_regular, color: AppColors.primary), 150 149 filled: true, 151 150 fillColor: AppColors.white.withAlpha(255), ··· 156 155 textInputAction: TextInputAction.next, 157 156 keyboardType: TextInputType.emailAddress, 158 157 autofillHints: const [AutofillHints.username, AutofillHints.email], 159 - onEditingComplete: () => _passwordFocusNode.requestFocus(), 158 + onEditingComplete: _passwordFocusNode.requestFocus, 160 159 ), 161 160 const SizedBox(height: 16), 162 161 ··· 165 164 focusNode: _passwordFocusNode, 166 165 decoration: InputDecoration( 167 166 hintText: 'Password', 168 - hintStyle: TextStyle(color: AppColors.hintText), 167 + hintStyle: const TextStyle(color: AppColors.hintText), 169 168 prefixIcon: const Icon(FluentIcons.lock_closed_24_regular, color: AppColors.primary), 170 169 suffixIcon: IconButton( 171 170 onPressed: () { ··· 239 238 padding: const EdgeInsets.only(bottom: 16), 240 239 child: Text( 241 240 switch (error) { 242 - String e when e.contains('must be a valid handle') => 'Invalid handle', 243 - String e when e.contains('identifier or password') => 'Invalid handle or password', 241 + final String e when e.contains('must be a valid handle') => 'Invalid handle', 242 + final String e when e.contains('identifier or password') => 'Invalid handle or password', 244 243 _ => error, 245 244 }, 246 245 style: const TextStyle(color: AppColors.error, fontSize: 14),
+16 -16
lib/src/features/auth/ui/pages/onboarding_page.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:sparksocial/src/core/auth/data/models/onboarding_screen_state.dart'; // Import for OnboardingScreenState 4 5 import 'package:sparksocial/src/core/routing/app_router.dart'; 5 - import 'package:sparksocial/src/core/auth/data/models/onboarding_screen_state.dart'; // Import for OnboardingScreenState 6 + import 'package:sparksocial/src/core/widgets/custom_text_field.dart'; // Corrected path 6 7 import 'package:sparksocial/src/features/auth/providers/onboarding_notifier.dart'; 7 8 import 'package:sparksocial/src/features/auth/providers/onboarding_providers.dart'; 8 9 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 9 10 import 'package:sparksocial/src/features/settings/ui/pages/profile_settings_page.dart'; 10 - import 'package:sparksocial/src/core/widgets/custom_text_field.dart'; // Corrected path 11 11 12 12 @RoutePage() 13 13 class OnboardingPage extends ConsumerStatefulWidget { ··· 92 92 context.router.replaceAll([const MainRoute()]); 93 93 } catch (e) { 94 94 if (!mounted) return; 95 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error completing profile: ${e.toString()}'))); 95 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error completing profile: $e'))); 96 96 } finally { 97 97 if (mounted) { 98 98 setState(() { ··· 137 137 child: Column( 138 138 mainAxisSize: MainAxisSize.min, 139 139 children: [ 140 - Text('Error: ${err.toString()}'), 140 + Text('Error: $err'), 141 141 const SizedBox(height: 8), 142 - ElevatedButton(onPressed: () => notifier.reloadProfile(), child: const Text('Retry')), 142 + ElevatedButton(onPressed: notifier.reloadProfile, child: const Text('Retry')), 143 143 ], 144 144 ), 145 145 ), ··· 151 151 avatarImageProvider = NetworkImage(notifier.currentAvatarDisplayUrl!); 152 152 } 153 153 154 - final bool hasLocalAvatar = state.localAvatarBytes != null; 155 - final bool isAvatarActive = hasLocalAvatar || notifier.currentAvatarDisplayUrl != null; 154 + final hasLocalAvatar = state.localAvatarBytes != null; 155 + final isAvatarActive = hasLocalAvatar || notifier.currentAvatarDisplayUrl != null; 156 156 157 157 return Center( 158 158 child: Padding( ··· 167 167 alignment: Alignment.bottomRight, // Changed alignment for better visibility 168 168 children: [ 169 169 GestureDetector( 170 - onTap: () => notifier.pickAvatar(), 170 + onTap: notifier.pickAvatar, 171 171 child: CircleAvatar( 172 172 radius: 50, 173 173 backgroundImage: avatarImageProvider, ··· 186 186 children: [ 187 187 if (hasLocalAvatar) // Show undo if a local avatar is picked 188 188 Padding( 189 - padding: const EdgeInsets.all(4.0), 189 + padding: const EdgeInsets.all(4), 190 190 child: GestureDetector( 191 - onTap: () => notifier.revertAvatarToInitial(), 191 + onTap: notifier.revertAvatarToInitial, 192 192 child: Container( 193 - decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), 193 + decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle), 194 194 padding: const EdgeInsets.all(4), 195 195 child: const Icon(Icons.undo, size: 16, color: Colors.white), 196 196 ), ··· 198 198 ), 199 199 if (isAvatarActive) // Show close if any avatar is active 200 200 Padding( 201 - padding: const EdgeInsets.all(4.0), 201 + padding: const EdgeInsets.all(4), 202 202 child: GestureDetector( 203 - onTap: () => notifier.clearAvatarSelection(), 203 + onTap: notifier.clearAvatarSelection, 204 204 child: Container( 205 - decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), 205 + decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle), 206 206 padding: const EdgeInsets.all(4), 207 207 child: const Icon(Icons.close, size: 16, color: Colors.white), 208 208 ), ··· 227 227 onUndo: 228 228 (state.bskyProfileRecord?.displayName != null && 229 229 _displayNameController.text != (state.bskyProfileRecord?.displayName ?? '')) 230 - ? () => notifier.resetDisplayName() 230 + ? notifier.resetDisplayName 231 231 : null, 232 232 validator: (value) { 233 233 if (value == null || value.trim().isEmpty) return 'Display Name is required'; ··· 244 244 onUndo: 245 245 (state.bskyProfileRecord?.description != null && 246 246 _descriptionController.text != (state.bskyProfileRecord?.description ?? '')) 247 - ? () => notifier.resetDescription() 247 + ? notifier.resetDescription 248 248 : null, 249 249 validator: (value) { 250 250 if (value != null && value.trim().length > 256) return 'Bio cannot exceed 256 characters';
+17 -17
lib/src/features/auth/ui/pages/register_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 1 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 5 import 'package:flutter_svg/flutter_svg.dart'; 5 - import 'package:auto_route/auto_route.dart'; 6 6 import 'package:sparksocial/src/core/config/app_config.dart'; 7 7 import 'package:sparksocial/src/core/routing/app_router.dart'; 8 8 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; ··· 43 43 _errorMessage = null; 44 44 }); 45 45 46 - final String handle = "${_handleController.text}.sprk.so"; 46 + final handle = '${_handleController.text}.sprk.so'; 47 47 48 48 final authNotifier = ref.read(authProvider.notifier); 49 49 final result = await authNotifier.register( ··· 93 93 child: GestureDetector( 94 94 onTap: () => FocusScope.of(context).unfocus(), 95 95 child: Padding( 96 - padding: const EdgeInsets.symmetric(horizontal: 24.0), 96 + padding: const EdgeInsets.symmetric(horizontal: 24), 97 97 child: ListView( 98 98 children: [ 99 99 const SizedBox(height: 24), 100 100 Center(child: SvgPicture.asset('assets/images/logo_dark_mode.svg', height: 140, width: 140)), 101 101 const SizedBox(height: 21), 102 - Center( 102 + const Center( 103 103 child: Text( 104 104 'Create Account', 105 105 style: TextStyle(color: AppColors.white, fontSize: 26, fontWeight: FontWeight.bold), ··· 114 114 alignment: WrapAlignment.center, 115 115 crossAxisAlignment: WrapCrossAlignment.center, 116 116 children: [ 117 - Text('Create your new ', style: TextStyle(color: AppColors.white, fontSize: 20, height: 1.7)), 117 + const Text('Create your new ', style: TextStyle(color: AppColors.white, fontSize: 20, height: 1.7)), 118 118 SvgPicture.asset('assets/images/ataccount.svg', height: 25, width: 100), 119 119 const SizedBox(width: 4), 120 120 const ATAccountInfoIcon(), ··· 129 129 Container( 130 130 padding: const EdgeInsets.all(16), 131 131 decoration: BoxDecoration(color: AppColors.error.withAlpha(26), borderRadius: BorderRadius.circular(12)), 132 - child: Row( 132 + child: const Row( 133 133 children: [ 134 - const Icon(FluentIcons.warning_24_regular, color: AppColors.error), 135 - const SizedBox(width: 8), 136 - const Expanded( 134 + Icon(FluentIcons.warning_24_regular, color: AppColors.error), 135 + SizedBox(width: 8), 136 + Expanded( 137 137 child: Text( 138 138 'New account registration is currently disabled while we correct issues in our system. We will try to re-enable it as soon as possible.', 139 139 style: TextStyle(color: AppColors.error), ··· 144 144 ), 145 145 const SizedBox(height: 24), 146 146 ], 147 - Text( 147 + const Text( 148 148 'Email', 149 149 style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.white), 150 150 ), ··· 154 154 keyboardType: TextInputType.emailAddress, 155 155 decoration: InputDecoration( 156 156 hintText: 'Your email address', 157 - hintStyle: TextStyle(color: AppColors.hintText), 157 + hintStyle: const TextStyle(color: AppColors.hintText), 158 158 filled: true, 159 159 fillColor: AppColors.white.withAlpha(255), 160 160 border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), ··· 166 166 ), 167 167 168 168 const SizedBox(height: 24), 169 - Text( 169 + const Text( 170 170 'Username', 171 171 style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.white), 172 172 ), ··· 175 175 controller: _handleController, 176 176 decoration: InputDecoration( 177 177 hintText: 'username', 178 - hintStyle: TextStyle(color: AppColors.hintText), 178 + hintStyle: const TextStyle(color: AppColors.hintText), 179 179 filled: true, 180 180 fillColor: AppColors.white.withAlpha(255), 181 181 border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), ··· 183 183 suffixIcon: Padding( 184 184 padding: const EdgeInsets.only(right: 16), 185 185 child: Center( 186 - widthFactor: 1.0, 186 + widthFactor: 1, 187 187 child: Text('.sprk.so', style: TextStyle(fontSize: 16, color: colorScheme.primary)), 188 188 ), 189 189 ), ··· 195 195 196 196 const SizedBox(height: 24), 197 197 198 - Text( 198 + const Text( 199 199 'Password', 200 200 style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.white), 201 201 ), ··· 205 205 obscureText: !_isPasswordVisible, 206 206 decoration: InputDecoration( 207 207 hintText: 'Your password', 208 - hintStyle: TextStyle(color: AppColors.hintText), 208 + hintStyle: const TextStyle(color: AppColors.hintText), 209 209 filled: true, 210 210 fillColor: AppColors.white.withAlpha(255), 211 211 border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), ··· 272 272 Row( 273 273 mainAxisAlignment: MainAxisAlignment.center, 274 274 children: [ 275 - Text('Already have an account?', style: TextStyle(color: AppColors.white)), 275 + const Text('Already have an account?', style: TextStyle(color: AppColors.white)), 276 276 TextButton( 277 277 onPressed: () => context.router.push(const LoginRoute()), 278 278 child: const Text(
+5 -7
lib/src/features/auth/ui/widgets/at_account_dialog.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_svg/flutter_svg.dart'; 4 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 5 import 'package:url_launcher/url_launcher.dart'; 5 - 6 - import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 6 8 7 /// A widget that displays an information icon for AT Accounts. 9 8 /// When tapped, it shows a dialog explaining what an AT Account is. ··· 40 39 return AlertDialog( 41 40 backgroundColor: AppColors.deepPurple, 42 41 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 43 - titlePadding: const EdgeInsets.only(top: 32, left: 24, right: 24, bottom: 0), 42 + titlePadding: const EdgeInsets.only(top: 32, left: 24, right: 24), 44 43 contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 0), 45 44 title: Column( 46 45 children: [ 47 46 SvgPicture.asset('assets/images/ataccount.svg', height: 40), 48 47 const SizedBox(height: 18), 49 - Text( 48 + const Text( 50 49 'What is an AT Account?', 51 50 style: TextStyle(color: AppColors.lightLavender, fontWeight: FontWeight.bold, fontSize: 20), 52 51 textAlign: TextAlign.center, ··· 77 76 78 77 /// Actions for the AT Account dialog. 79 78 class _ATAccountDialogActions extends StatelessWidget { 79 + const _ATAccountDialogActions({required this.onGotIt, required this.onLearnMore}); 80 80 final VoidCallback onGotIt; 81 81 final VoidCallback onLearnMore; 82 - 83 - const _ATAccountDialogActions({required this.onGotIt, required this.onLearnMore}); 84 82 85 83 @override 86 84 Widget build(BuildContext context) { ··· 89 87 Expanded( 90 88 child: TextButton( 91 89 onPressed: onLearnMore, 92 - child: Text( 90 + child: const Text( 93 91 'Learn more', 94 92 style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 16), 95 93 ),
+6 -8
lib/src/features/comments/providers/comment_input_provider.dart
··· 16 16 ref.onDispose(() { 17 17 textController.dispose(); 18 18 }); 19 - textController.addListener(() { 20 - updateCanSubmit(); 21 - }); 19 + textController.addListener(updateCanSubmit); 22 20 return CommentInputState(textController: textController, imagePicker: imagePicker); 23 21 } 24 22 25 - late final _logger = GetIt.instance.get<LogService>().getLogger('CommentInputNotifier'); 23 + late final SparkLogger _logger = GetIt.instance.get<LogService>().getLogger('CommentInputNotifier'); 26 24 27 25 void updateCanSubmit() { 28 26 final textIsNotEmpty = state.textController.text.trim().isNotEmpty; ··· 61 59 final currentImageCount = state.selectedImages.length; 62 60 if (currentImageCount >= maxImages) { 63 61 if (!context.mounted) return; 64 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('You can select up to $maxImages images.'))); 62 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('You can select up to $maxImages images.'))); 65 63 return; 66 64 } 67 65 68 66 try { 69 - final List<XFile> pickedFiles = await state.imagePicker.pickMultiImage(limit: maxImages - currentImageCount); 67 + final pickedFiles = await state.imagePicker.pickMultiImage(limit: maxImages - currentImageCount); 70 68 if (pickedFiles.isNotEmpty) { 71 69 state = state.copyWith(selectedImages: [...state.selectedImages, ...pickedFiles]); 72 70 updateCanSubmit(); ··· 114 112 } catch (e) { 115 113 // If posting fails, just reset isPosting but keep the form data 116 114 state = state.copyWith(isPosting: false); 117 - 115 + 118 116 // Log the error for debugging 119 117 _logger.e('Error posting comment: $e'); 120 - 118 + 121 119 rethrow; 122 120 } 123 121 }
+2 -2
lib/src/features/comments/providers/comment_input_state.dart
··· 7 7 @freezed 8 8 class CommentInputState with _$CommentInputState { 9 9 const factory CommentInputState({ 10 + required TextEditingController textController, 11 + required ImagePicker imagePicker, 10 12 @Default(false) bool canSubmit, 11 13 @Default(false) bool isPosting, 12 14 @Default([]) List<XFile> selectedImages, 13 15 @Default({}) Map<String, String> altTexts, 14 - required TextEditingController textController, 15 - required ImagePicker imagePicker, 16 16 }) = _CommentInputState; 17 17 }
+11 -12
lib/src/features/comments/providers/comment_provider.dart
··· 33 33 Future<void> toggleLike() async { 34 34 final wasLiked = state.isLiked; 35 35 final currentLikeCount = state.thread.post.likeCount ?? 0; 36 - 36 + 37 37 try { 38 38 if (wasLiked) { 39 39 // Capture the like reference before optimistic update 40 40 final likeUri = state.thread.post.viewer!.like!; 41 - 41 + 42 42 // Optimistically update UI for unlike 43 43 final updatedPost = state.thread.post.copyWith( 44 44 viewer: state.thread.post.viewer?.copyWith(like: null), ··· 47 47 state = state.copyWith( 48 48 thread: state.thread.copyWith(post: updatedPost), 49 49 ); 50 - 50 + 51 51 // Perform the actual unlike using the captured reference 52 52 await _feedRepository.unlikePost(likeUri); 53 - 53 + 54 54 // Trigger UI updates 55 55 ref.read(postUpdateProvider(state.thread.post.uri.toString()).notifier).state++; 56 56 } else { 57 57 // Optimistically update UI for like 58 58 final response = await _feedRepository.likePost(state.thread.post.cid, state.thread.post.uri); 59 - 59 + 60 60 final updatedPost = state.thread.post.copyWith( 61 - viewer: state.thread.post.viewer?.copyWith(like: response.uri) ?? 62 - Viewer(like: response.uri), 61 + viewer: state.thread.post.viewer?.copyWith(like: response.uri) ?? Viewer(like: response.uri), 63 62 likeCount: currentLikeCount + 1, 64 63 ); 65 64 state = state.copyWith( 66 65 thread: state.thread.copyWith(post: updatedPost), 67 66 ); 68 - 67 + 69 68 // Trigger UI updates 70 69 ref.read(postUpdateProvider(state.thread.post.uri.toString()).notifier).state++; 71 70 } 72 71 } catch (e) { 73 72 // Revert optimistic update on error 74 73 final revertedPost = state.thread.post.copyWith( 75 - viewer: wasLiked 76 - ? state.thread.post.viewer?.copyWith(like: state.thread.post.viewer?.like) 77 - : state.thread.post.viewer?.copyWith(like: null), 74 + viewer: wasLiked 75 + ? state.thread.post.viewer?.copyWith(like: state.thread.post.viewer?.like) 76 + : state.thread.post.viewer?.copyWith(like: null), 78 77 likeCount: currentLikeCount, 79 78 ); 80 79 state = state.copyWith( ··· 102 101 Map<String, String>? altTexts, 103 102 }) async { 104 103 final feedRepository = GetIt.instance<SprkRepository>().feed; 105 - return await feedRepository.postComment( 104 + return feedRepository.postComment( 106 105 text, 107 106 parentCid, 108 107 AtUri.parse(parentUri),
+3 -4
lib/src/features/comments/providers/comment_state.dart
··· 5 5 6 6 @freezed 7 7 class CommentState with _$CommentState { 8 - const CommentState._(); 9 - 10 8 const factory CommentState({ 11 9 required ThreadViewPost thread, 12 10 @Default(false) bool isVideoInitialized, 13 11 @Default(false) bool isFirstImagePrecached, 14 12 String? videoUrl, 15 13 }) = _CommentState; 16 - 14 + const CommentState._(); 15 + 17 16 // Derive isLiked from the viewer state 18 17 bool get isLiked => thread.post.viewer?.like != null; 19 - 18 + 20 19 // Get the actual like count, potentially adjusted for optimistic UI updates 21 20 int get likeCount => thread.post.likeCount ?? 0; 22 21 }
+9 -11
lib/src/features/comments/ui/pages/comments_page.dart
··· 11 11 12 12 @RoutePage() 13 13 class CommentsPage extends ConsumerStatefulWidget { 14 + const CommentsPage({required this.postUri, required this.isSprk, super.key, this.post}); 14 15 final String postUri; 15 16 final bool isSprk; 16 17 final PostView? post; 17 - 18 - const CommentsPage({super.key, required this.postUri, required this.isSprk, this.post}); 19 18 20 19 @override 21 20 ConsumerState<CommentsPage> createState() => _CommentsPageState(); ··· 56 55 color: backgroundColor, 57 56 borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), 58 57 ), 59 - child: ClipRRect( 60 - borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), 58 + child: const ClipRRect( 59 + borderRadius: BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), 61 60 child: AutoRouter(), 62 61 ), 63 62 ), ··· 195 194 padding: const EdgeInsets.only(bottom: 16), 196 195 itemCount: data.thread.replies?.length ?? 0, 197 196 itemBuilder: (context, index) { 198 - final comment = data.thread.replies?[index] as ThreadViewPost; 197 + final comment = data.thread.replies![index] as ThreadViewPost; 199 198 return CommentItem(key: ValueKey('comment-${comment.post.cid}'), thread: comment, mainPostUri: _postAtUri); 200 199 }, 201 200 ); ··· 222 221 223 222 // Separate widget to handle keyboard awareness without rebuilding the provider 224 223 class _KeyboardAwareCommentInput extends StatelessWidget { 225 - final String videoId; 226 - final String postCid; 227 - final String postUri; 228 - final bool isSprk; 229 - final FocusNode focusNode; 230 - 231 224 const _KeyboardAwareCommentInput({ 232 225 required this.videoId, 233 226 required this.postCid, ··· 235 228 required this.isSprk, 236 229 required this.focusNode, 237 230 }); 231 + final String videoId; 232 + final String postCid; 233 + final String postUri; 234 + final bool isSprk; 235 + final FocusNode focusNode; 238 236 239 237 @override 240 238 Widget build(BuildContext context) {
+4 -5
lib/src/features/comments/ui/pages/replies_page.dart
··· 10 10 11 11 @RoutePage() 12 12 class RepliesPage extends ConsumerStatefulWidget { 13 + const RepliesPage({required this.postUri, super.key}); 13 14 final String postUri; 14 - 15 - const RepliesPage({super.key, required this.postUri}); 16 15 17 16 @override 18 17 ConsumerState<RepliesPage> createState() => _RepliesPageState(); ··· 84 83 children: [ 85 84 if (data.thread.parent is ThreadViewPost) 86 85 CommentItem( 87 - key: ValueKey('comment-${(data.thread.parent as ThreadViewPost).post.uri}'), 88 - thread: data.thread.parent as ThreadViewPost, 86 + key: ValueKey('comment-${(data.thread.parent! as ThreadViewPost).post.uri}'), 87 + thread: data.thread.parent! as ThreadViewPost, 89 88 mainPostUri: AtUri.parse(widget.postUri), 90 89 ), 91 90 Expanded( ··· 96 95 ), 97 96 itemCount: data.thread.replies?.length ?? 0, 98 97 itemBuilder: (context, index) { 99 - final comment = data.thread.replies?[index] as ThreadViewPost; 98 + final comment = data.thread.replies![index] as ThreadViewPost; 100 99 return CommentItem( 101 100 key: ValueKey('comment-${comment.post.cid}'), 102 101 thread: comment,
+23 -27
lib/src/features/comments/ui/widgets/comment_input.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 6 import 'package:image_picker/image_picker.dart'; // Import image_picker 7 + import 'package:sparksocial/src/core/widgets/alt_text_editor_dialog.dart'; 8 + import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 7 9 import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 10 + import 'package:sparksocial/src/features/comments/providers/comment_input_provider.dart'; 8 11 import 'package:sparksocial/src/features/comments/providers/comment_input_state.dart'; 9 - import 'package:sparksocial/src/features/comments/providers/comment_input_provider.dart'; 10 - import 'package:sparksocial/src/core/widgets/alt_text_editor_dialog.dart'; 11 - import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 12 + import 'package:sparksocial/src/features/comments/ui/widgets/emoji_picker.dart'; 12 13 import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 13 14 14 - import 'emoji_picker.dart'; 15 - 16 15 class CommentInputWidget extends ConsumerStatefulWidget { 16 + const CommentInputWidget({ 17 + required this.videoId, 18 + required this.postCid, 19 + required this.postUri, 20 + required this.isSprk, 21 + super.key, 22 + this.focusNode, 23 + this.rootCid, 24 + this.rootUri, 25 + }); 17 26 final String videoId; 18 27 // Video post info 19 28 final String postCid; ··· 24 33 final FocusNode? focusNode; 25 34 final bool isSprk; 26 35 27 - const CommentInputWidget({ 28 - super.key, 29 - required this.videoId, 30 - required this.postCid, 31 - required this.postUri, 32 - this.focusNode, 33 - required this.isSprk, 34 - this.rootCid, 35 - this.rootUri, 36 - }); 37 - 38 36 @override 39 37 ConsumerState<CommentInputWidget> createState() => _CommentInputState(); 40 38 } ··· 72 70 decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(32)), 73 71 padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), 74 72 child: Row( 75 - crossAxisAlignment: CrossAxisAlignment.center, 76 73 children: [ 77 74 UserAvatar( 78 75 imageUrl: ref ··· 84 81 ), 85 82 username: session?.handle ?? '', 86 83 size: 28, 87 - borderWidth: 0, 88 84 ), 89 85 const SizedBox(width: 8), 90 86 _AttachmentButton( ··· 112 108 // Selected Images Preview (only show if images are selected) 113 109 if (state.selectedImages.isNotEmpty) 114 110 Padding( 115 - padding: const EdgeInsets.only(top: 8.0), 111 + padding: const EdgeInsets.only(top: 8), 116 112 child: _SelectedImagesPreview(state: state, notifier: notifier), 117 113 ), 118 114 ], ··· 140 136 141 137 @override 142 138 Widget build(BuildContext context) { 143 - String hint = 'Add a comment...'; 139 + const hint = 'Add a comment...'; 144 140 145 141 return TextField( 146 142 controller: state.textController, ··· 216 212 217 213 @override 218 214 Widget build(BuildContext context) { 219 - final bool canAddMoreImages = state.selectedImages.length < 4; 220 - final bool enabled = !state.isPosting && canAddMoreImages; 215 + final canAddMoreImages = state.selectedImages.length < 4; 216 + final enabled = !state.isPosting && canAddMoreImages; 221 217 222 218 return IconButton( 223 219 padding: EdgeInsets.zero, ··· 247 243 final imageFile = state.selectedImages[index]; 248 244 final alt = state.altTexts[imageFile.path]; 249 245 return Padding( 250 - padding: const EdgeInsets.only(right: 8.0), 246 + padding: const EdgeInsets.only(right: 8), 251 247 child: Stack( 252 248 alignment: Alignment.bottomRight, 253 249 children: [ ··· 280 276 } 281 277 }, 282 278 borderRadius: BorderRadius.circular(8), 283 - child: Padding( 284 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 279 + child: const Padding( 280 + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), 285 281 child: Row( 286 282 children: [ 287 283 Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 14), 288 - const SizedBox(width: 2), 289 - const Text( 284 + SizedBox(width: 2), 285 + Text( 290 286 'ALT', 291 287 style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), 292 288 ),
+7 -14
lib/src/features/comments/ui/widgets/comment_item.dart
··· 18 18 import 'package:sparksocial/src/features/comments/providers/comments_page_provider.dart'; 19 19 20 20 class CommentItem extends ConsumerStatefulWidget { 21 + const CommentItem({required this.thread, required this.mainPostUri, super.key}); 21 22 final ThreadViewPost thread; 22 23 final AtUri mainPostUri; 23 - const CommentItem({super.key, required this.thread, required this.mainPostUri}); 24 24 25 25 @override 26 26 ConsumerState<CommentItem> createState() => _CommentItemState(); ··· 103 103 @override 104 104 Widget build(BuildContext context) { 105 105 commentState = ref.watch(commentNotifierProvider(widget.thread)); 106 - const double thumbnailSize = 120.0; 106 + const double thumbnailSize = 120; 107 107 108 108 final borderRadius = BorderRadius.circular(8); 109 - final bool hasImages = commentState.thread.post.embed is EmbedViewImage; 110 - final bool hasVideo = commentState.thread.post.embed is EmbedViewVideo; 109 + final hasImages = commentState.thread.post.embed is EmbedViewImage; 110 + final hasVideo = commentState.thread.post.embed is EmbedViewVideo; 111 111 112 112 return Column( 113 113 crossAxisAlignment: CrossAxisAlignment.start, ··· 133 133 child: GestureDetector( 134 134 onTap: _navigateToProfile, 135 135 child: Row( 136 - crossAxisAlignment: CrossAxisAlignment.center, 137 136 children: [ 138 137 Text( 139 138 commentState.thread.post.author.handle, ··· 152 151 ), 153 152 ), 154 153 MenuActionButton( 155 - onPressed: () { 156 - _handleReportComment(); 157 - }, 158 - onDeletePressed: () { 159 - _handleDeleteComment(); 160 - }, 154 + onPressed: _handleReportComment, 155 + onDeletePressed: _handleDeleteComment, 161 156 isCompact: true, 162 157 backgroundColor: Theme.of(context).colorScheme.surface, 163 - isProfile: false, 164 158 authorDid: commentState.thread.post.author.did, 165 159 ), 166 160 ], ··· 236 230 margin: const EdgeInsets.only(left: 64), 237 231 padding: const EdgeInsets.only(top: 4, bottom: 8), 238 232 decoration: BoxDecoration( 239 - border: Border(left: BorderSide(color: Theme.of(context).colorScheme.surface, width: 1)), 233 + border: Border(left: BorderSide(color: Theme.of(context).colorScheme.surface)), 240 234 ), 241 235 child: Text('Show ${commentState.thread.post.replyCount} replies'), 242 236 ), ··· 305 299 imageUrl: widget.thread.post.author.avatar.toString(), 306 300 username: widget.thread.post.author.handle, 307 301 size: 36, 308 - borderWidth: 0, 309 302 ); 310 303 } 311 304 }
+1 -4
lib/src/features/comments/ui/widgets/emoji_picker.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 3 4 - 5 4 class EmojiPicker extends StatelessWidget { 5 + const EmojiPicker({required this.onEmojiSelected, required this.isDarkMode, super.key}); 6 6 final Function(String) onEmojiSelected; 7 7 final bool isDarkMode; 8 8 static const List<String> _emojis = ['❤️', '😂', '👍', '🔥', '😍', '🙌', '👏', '🎉', '😮', '🤔', '👀', '💯', '🤣', '😊', '🙏']; 9 - 10 - const EmojiPicker({super.key, required this.onEmojiSelected, required this.isDarkMode}); 11 9 12 10 // Common emojis list 13 11 @override ··· 42 40 43 41 final String emoji; 44 42 final Function(String) onEmojiSelected; 45 - 46 43 47 44 @override 48 45 Widget build(BuildContext context) {
+1 -1
lib/src/features/feed/providers/delete_post.dart
··· 23 23 } catch (e) { 24 24 throw Exception('Failed to delete post: $e'); 25 25 } 26 - } 26 + }
+36 -35
lib/src/features/feed/providers/feed_provider.dart
··· 5 5 import 'package:atproto/core.dart'; 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:riverpod_annotation/riverpod_annotation.dart'; 8 - import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 9 8 import 'package:sparksocial/src/core/feed_algorithms/hardcoded_feed_algorithm.dart'; 9 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 10 + import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 10 11 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 11 12 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 13 + import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 12 14 import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; 13 15 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 14 16 import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; ··· 16 18 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 17 19 import 'package:sparksocial/src/features/feed/providers/feed_state.dart'; 18 20 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 19 - import 'package:sparksocial/src/core/storage/cache/cache_manager_interface.dart'; 20 - import 'package:sparksocial/src/core/network/atproto/data/models/labeler_models.dart'; 21 21 22 22 part 'feed_provider.g.dart'; 23 23 ··· 60 60 // If we were waiting at the end of the feed and new posts have arrived 61 61 if (_isWaitingForFreshPostsAtEnd && next.freshPostCount > 0) { 62 62 _logger.d('New posts arrived! Loading...'); 63 - Future.microtask(() => load()); // Prevent synchronous execution during state change 63 + Future.microtask(load); // Prevent synchronous execution during state change 64 64 } 65 65 66 66 // Update preserved state whenever state changes 67 67 _preservedState = next; 68 68 }); 69 69 70 - var isActive = ref.watch(settingsProvider).activeFeed == feed; 70 + final isActive = ref.watch(settingsProvider).activeFeed == feed; 71 71 72 72 // If this notifier has been built before and we have preserved state, use it 73 73 if (_hasBeenBuilt && _preservedState != null) { ··· 101 101 bool _isInitialized() { 102 102 try { 103 103 // Try to access one of the late final fields 104 + // ignore: unnecessary_statements 104 105 _feedRepository; 105 106 return true; 106 107 } catch (e) { ··· 148 149 149 150 // gets ONLY the first f cached posts from the database (not all) 150 151 final uriStrings = await _sqlCache.getUrisForFeed(_feed, limit: FeedState.firstLoadLimit); 151 - final uris = uriStrings.map((e) => AtUri.parse(e)).toList(); 152 - List<Label> labels = <Label>[]; 152 + final uris = uriStrings.map(AtUri.parse).toList(); 153 + final labels = <Label>[]; 153 154 154 155 // adds the initial uris to the list of initial uris so that they are not fetched again 155 156 _initialUris.addAll(uris); ··· 158 159 if (uris.isNotEmpty) { 159 160 // Get existing cached posts to preserve viewer information (like status) 160 161 final cachedPosts = await _sqlCache.getPostsByUris(uris); 161 - final cachedPostsMap = {for (var post in cachedPosts) post.uri: post}; 162 + final cachedPostsMap = {for (final post in cachedPosts) post.uri: post}; 162 163 163 164 // gets the subscribed labels for the posts 164 165 final followedLabelers = await _settingsRepository.getFollowedLabelers(); ··· 168 169 final updatedPostViews = await _feedRepository.getPosts(uris, bluesky: _shouldUseBlueskyAPI()); 169 170 170 171 // Preserve viewer information from cached posts when updating with fresh data 171 - final List<PostView> mergedPosts = []; 172 - for (var freshPost in updatedPostViews) { 172 + final mergedPosts = <PostView>[]; 173 + for (final freshPost in updatedPostViews) { 173 174 final cachedPost = cachedPostsMap[freshPost.uri]; 174 175 PostView finalPost; 175 176 ··· 183 184 mergedPosts.add(finalPost); 184 185 } 185 186 186 - for (var post in mergedPosts) { 187 + for (final post in mergedPosts) { 187 188 labels.addAll(post.labels ?? []); // labels from the post 188 189 if (post.record.selfLabels != null) { 189 190 final recordLabels = <Label>[]; 190 - for (SelfLabel selfLabel in post.record.selfLabels!) { 191 + for (final selfLabel in post.record.selfLabels!) { 191 192 recordLabels.add( 192 193 Label(uri: post.uri.toString(), value: selfLabel.value, src: post.uri.toString(), createdAt: post.indexedAt), 193 194 ); ··· 200 201 } 201 202 202 203 // Store the cursor from the initial fetch 203 - String? newCursor = state.cursor; 204 - int fetchedCount = 0; 204 + // String? newCursor = state.cursor; 205 + var fetchedCount = 0; 205 206 // starts fetching and storing new posts 206 207 if (!state.isEndOfNetworkFeed) { 207 208 final (int count, List<AtUri> fetchedUris, String? cursor) = await fetch(); 208 - newCursor = cursor; 209 + // newCursor = cursor; 209 210 fetchedCount = count; 210 211 if (count > 0) { 211 212 await store(fetchedUris); ··· 219 220 state.extraInfo, 220 221 ); 221 222 222 - for (Label newLabel in labels) { 223 + for (final newLabel in labels) { 223 224 final uri = AtUri.parse(newLabel.uri); 224 225 extraInfo.update(uri, (value) { 225 226 final existingLabels = value.postLabels; ··· 261 262 loadedPosts: filteredUris, 262 263 freshPostCount: 0, // Set to 0 as per strategy 263 264 extraInfo: extraInfo, 264 - cursor: newCursor, // Store the cursor from fetch 265 + // cursor: newCursor, // Store the cursor from fetch 265 266 loadingFirstLoad: loadingFirstLoad, 266 267 ); 267 268 _isWaitingForFreshPostsAtEnd = state.length <= 1; ··· 293 294 Future<void> store(List<AtUri> uris) async { 294 295 _logger.d('Store called with ${uris.length} URIs. Current freshPostCount: ${state.freshPostCount}'); 295 296 _isCaching = true; // Set caching flag immediately 296 - int updatedPostCount = 0; 297 + var updatedPostCount = 0; 297 298 state = state.copyWith(error: false); 298 299 try { 299 300 // checks if the posts have already been cached ··· 304 305 305 306 // Get existing cached posts to preserve viewer information (like status) 306 307 final cachedPosts = await _sqlCache.getPostsByUris(existingUris); 307 - final cachedPostsMap = {for (var post in cachedPosts) post.uri: post}; 308 + final cachedPostsMap = {for (final post in cachedPosts) post.uri: post}; 308 309 309 310 final posts = await _feedRepository.getPosts(existingUris, bluesky: _shouldUseBlueskyAPI()); 310 311 311 312 // Preserve viewer information from cached posts when updating with fresh data 312 - final List<PostView> mergedPosts = []; 313 - for (var freshPost in posts) { 313 + final mergedPosts = <PostView>[]; 314 + for (final freshPost in posts) { 314 315 final cachedPost = cachedPostsMap[freshPost.uri]; 315 316 PostView finalPost; 316 317 ··· 353 354 354 355 // gets the subscribed labels for the new posts 355 356 final followedLabelers = await _settingsRepository.getFollowedLabelers(); 356 - List<Label> newPostLabels = []; 357 + var newPostLabels = <Label>[]; 357 358 try { 358 359 final (cursor: _, labels: fetchedLabels) = await _feedRepository.getLabels(nonExistingUris, sources: followedLabelers); 359 360 newPostLabels = fetchedLabels; ··· 362 363 newPostLabels = []; 363 364 } 364 365 365 - List<PostView> postsWithLabels = []; 366 - for (var post in nonExistingPosts) { 366 + final postsWithLabels = <PostView>[]; 367 + for (final post in nonExistingPosts) { 367 368 newPostLabels.addAll(post.labels ?? []); // labels from the post 368 369 if (post.record.selfLabels != null) { 369 370 final recordLabels = <Label>[]; 370 - for (SelfLabel selfLabel in post.record.selfLabels!) { 371 + for (final selfLabel in post.record.selfLabels!) { 371 372 recordLabels.add( 372 373 Label(uri: post.uri.toString(), value: selfLabel.value, src: post.uri.toString(), createdAt: post.indexedAt), 373 374 ); ··· 377 378 postsWithLabels.add(post.copyWith(labels: newPostLabels)); 378 379 } 379 380 380 - int newPostsCached = 0; 381 - int errorCount = 0; 382 - for (PostView post in postsWithLabels) { 381 + var newPostsCached = 0; 382 + var errorCount = 0; 383 + for (final post in postsWithLabels) { 383 384 // concurrent execution 384 385 _downloadManager.submitTask( 385 386 DownloadTask( ··· 445 446 446 447 // gets the subscribed labels for the posts 447 448 final followedLabelers = await _settingsRepository.getFollowedLabelers(); 448 - List<Label> labels = []; 449 + var labels = <Label>[]; 449 450 try { 450 451 final (cursor: _, labels: fetchedLabels) = await _feedRepository.getLabels(uris, sources: followedLabelers); 451 452 labels = fetchedLabels; ··· 456 457 457 458 // Get the post data for the new URIs 458 459 final newPosts = posts.where((post) => uris.contains(post.uri)).toList(); 459 - for (var post in newPosts) { 460 + for (final post in newPosts) { 460 461 labels.addAll(post.labels ?? []); // labels from the post 461 462 if (post.record.selfLabels != null) { 462 463 final recordLabels = <Label>[]; 463 - for (SelfLabel selfLabel in post.record.selfLabels!) { 464 + for (final selfLabel in post.record.selfLabels!) { 464 465 recordLabels.add( 465 466 Label(uri: post.uri.toString(), value: selfLabel.value, src: post.uri.toString(), createdAt: post.indexedAt), 466 467 ); ··· 491 492 state.extraInfo, 492 493 ); 493 494 494 - for (Label newLabel in labels) { 495 + for (final newLabel in labels) { 495 496 final uri = AtUri.parse(newLabel.uri); 496 497 extraInfo.update(uri, (value) { 497 498 final existingLabels = value.postLabels; ··· 592 593 final updatedPosts = state.loadedPosts.where((e) => e != uri).toList(); 593 594 594 595 // Adjust the index if necessary 595 - int newIndex = currentIndex; 596 + var newIndex = currentIndex; 596 597 if (postIndex != -1) { 597 598 if (postIndex < currentIndex) { 598 599 // Post was deleted before current position, adjust index down ··· 611 612 } 612 613 } 613 614 614 - _logger.d('Removing post ${uri.toString()}, adjusting index from $currentIndex to $newIndex'); 615 + _logger.d('Removing post $uri, adjusting index from $currentIndex to $newIndex'); 615 616 state = state.copyWith(loadedPosts: updatedPosts, index: newIndex); 616 617 } 617 618 ··· 622 623 try { 623 624 final labelPreference = await _settingsRepository.getLabelPreference(label.value); 624 625 if (labelPreference.setting == Setting.hide || (labelPreference.adultOnly && hideAdultContent)) { 625 - _logger.d('Hiding post ${uri.toString()} due to label: ${label.value}'); 626 + _logger.d('Hiding post $uri due to label: ${label.value}'); 626 627 return true; 627 628 } 628 629 } catch (e) {
+15
lib/src/features/feed/providers/feed_refresh_trigger_provider.dart
··· 1 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 3 + 4 + class FeedRefreshTrigger extends StateNotifier<int> { 5 + FeedRefreshTrigger() : super(0); 6 + 7 + void trigger() { 8 + state++; 9 + } 10 + } 11 + 12 + final StateNotifierProviderFamily<FeedRefreshTrigger, int, Feed> feedRefreshTriggerProvider = 13 + StateNotifierProvider.family<FeedRefreshTrigger, int, Feed>( 14 + (ref, feed) => FeedRefreshTrigger(), 15 + );
+1 -1
lib/src/features/feed/providers/feed_state.dart
··· 9 9 10 10 @freezed 11 11 abstract class FeedState with _$FeedState { 12 - const FeedState._(); 13 12 const factory FeedState({ 14 13 required bool active, 15 14 required List<AtUri> loadedPosts, ··· 21 20 required bool error, 22 21 required LinkedHashMap<AtUri, ({List<Label> postLabels, HardcodedFeedExtraInfo? hardcodedFeedExtraInfo})> extraInfo, 23 22 }) = _FeedState; 23 + const FeedState._(); 24 24 25 25 int get length => loadedPosts.length; 26 26
+1 -1
lib/src/features/feed/providers/like_post.dart
··· 25 25 } catch (e) { 26 26 throw Exception('Failed to unlike post: $e'); 27 27 } 28 - } 28 + }
+1 -1
lib/src/features/feed/providers/post_updates.dart
··· 1 1 // Provider to track post updates by URI - when a post gets updated, this gets incremented 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 4 - final postUpdateProvider = StateProvider.family<int, String>((ref, postUri) => 0); 4 + final StateProviderFamily<int, String> postUpdateProvider = StateProvider.family<int, String>((ref, postUri) => 0);
+20 -10
lib/src/features/feed/ui/pages/feed_page.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 4 4 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 5 - 6 5 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 6 + import 'package:sparksocial/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 7 + import 'package:sparksocial/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 7 8 import 'package:sparksocial/src/features/feed/ui/widgets/post/feed_post_widget.dart'; 8 9 import 'package:sparksocial/src/features/feed/ui/widgets/post/no_more_posts.dart'; 9 - import 'package:sparksocial/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 10 10 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 11 11 12 12 class FeedPage extends ConsumerStatefulWidget { 13 - const FeedPage({super.key, required this.feed}); 13 + const FeedPage({required this.feed, super.key}); 14 14 15 15 final Feed feed; 16 16 ··· 20 20 21 21 class _FeedPageState extends ConsumerState<FeedPage> with AutomaticKeepAliveClientMixin { 22 22 late final PageController pageController; 23 + final _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>(); 23 24 bool _hasInitialized = false; 24 25 bool _isRefreshing = false; 25 26 ··· 46 47 final notifier = ref.read(feedNotifierProvider(widget.feed).notifier); 47 48 final shouldBeActive = ref.watch(settingsProvider.select((settings) => settings.activeFeed == widget.feed)); 48 49 50 + ref.listen(feedRefreshTriggerProvider(widget.feed), (previous, next) { 51 + if (previous != next) { 52 + _refreshIndicatorKey.currentState?.show(); 53 + } 54 + }); 55 + 49 56 // Initialize feed when it becomes active for the first time 50 57 WidgetsBinding.instance.addPostFrameCallback((_) { 51 58 if (!_hasInitialized && !state.loadingFirstLoad && state.length == 0 && !state.isEndOfNetworkFeed && !_isRefreshing) { ··· 61 68 } 62 69 }); 63 70 64 - onRefresh() async { 71 + Future<void> onRefresh() async { 65 72 if (_isRefreshing) return; 66 73 67 74 setState(() { ··· 82 89 } 83 90 84 91 return RefreshIndicator( 92 + key: _refreshIndicatorKey, 85 93 onRefresh: onRefresh, 86 94 child: state.loadingFirstLoad 87 95 ? const Center(child: CircularProgressIndicator()) 88 96 : state.error 89 - ? Column( 90 - children: [ 91 - const Text('Error loading feed'), 92 - TextButton(onPressed: onRefresh, child: const Text('Try again')), 93 - ], 97 + ? Center( 98 + child: Column( 99 + mainAxisAlignment: MainAxisAlignment.center, 100 + children: [ 101 + const Text('Error loading feed'), 102 + TextButton(onPressed: onRefresh, child: const Text('Try again')), 103 + ], 104 + ), 94 105 ) 95 106 : CacheablePageView.builder( 96 107 cachePageExtent: 1, ··· 98 109 key: PageStorageKey(widget.feed.identifier), 99 110 itemCount: state.length + (state.isEndOfNetworkFeed ? 1 : 0), 100 111 scrollDirection: Axis.vertical, 101 - pageSnapping: true, 102 112 restorationId: widget.feed.identifier, 103 113 physics: shouldBeActive ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), 104 114 onPageChanged: (index) {
+3 -7
lib/src/features/feed/ui/pages/feeds_page.dart
··· 1 - // ignore_for_file: dead_code 2 - 3 1 import 'package:auto_route/auto_route.dart'; 4 2 import 'package:flutter/material.dart'; 5 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 4 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 7 5 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 6 + import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 8 7 import 'package:sparksocial/src/features/feed/providers/feed_state.dart'; 9 8 import 'package:sparksocial/src/features/feed/ui/pages/feed_page.dart'; 10 9 import 'package:sparksocial/src/features/feed/ui/widgets/feed/feeds_bar.dart'; 11 10 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 12 - import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 13 11 14 12 @RoutePage() 15 13 class FeedsPage extends ConsumerStatefulWidget { ··· 91 89 // Check if we need to initialize or update the page controller 92 90 final needsInitialization = !_isInitialized; 93 91 final activeFeedChanged = _lastActiveFeed != activeFeed; 94 - final feedsListChanged = _lastFeedsList == null || 95 - _lastFeedsList!.length != feeds.length || 96 - !_lastFeedsList!.every((feed) => feeds.contains(feed)); 92 + final feedsListChanged = 93 + _lastFeedsList == null || _lastFeedsList!.length != feeds.length || !_lastFeedsList!.every(feeds.contains); 97 94 98 95 if (needsInitialization || activeFeedChanged || feedsListChanged) { 99 96 _updatePageController(feeds, activeFeed); ··· 115 112 PageView.builder( 116 113 controller: _pageController, 117 114 itemCount: feeds.length, 118 - pageSnapping: true, 119 115 onPageChanged: (index) { 120 116 // Prevent recursive updates 121 117 if (_isPageControllerUpdating) return;
+11 -13
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 1 2 import 'package:auto_route/auto_route.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 5 import 'package:get_it/get_it.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 7 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 + import 'package:sparksocial/src/core/routing/app_router.dart'; 7 9 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 10 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 11 + import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 8 12 import 'package:sparksocial/src/features/feed/providers/post_updates.dart'; 9 13 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 14 + import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 10 15 import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 11 - import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 12 16 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 13 - import 'package:sparksocial/src/core/routing/app_router.dart'; 14 - import 'package:atproto_core/atproto_core.dart'; 15 - import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 16 - import 'package:sparksocial/src/core/utils/label_utils.dart'; 17 17 18 18 @RoutePage() 19 19 class StandalonePostPage extends ConsumerStatefulWidget { 20 - const StandalonePostPage({super.key, required this.postUri}); 20 + const StandalonePostPage({required this.postUri, super.key}); 21 21 22 22 final String postUri; 23 23 ··· 53 53 // If cache fails, fetch from network 54 54 final feedRepository = GetIt.instance<SprkRepository>().feed; 55 55 final uri = AtUri.parse(widget.postUri); 56 - bool isBlueskyPost = false; 56 + var isBlueskyPost = false; 57 57 // try { 58 - isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); 58 + isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); 59 59 // } catch (e) { 60 - // what 60 + // what 61 61 // } 62 62 final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); 63 63 ··· 74 74 75 75 Future<void> _checkContentWarning(PostView postData) async { 76 76 final labels = postData.labels ?? []; 77 - 77 + 78 78 if (labels.isNotEmpty) { 79 79 final shouldShowWarning = await LabelUtils.shouldShowWarning(labels); 80 80 final shouldBlurContent = await LabelUtils.shouldBlurContent(labels); ··· 108 108 _lastUpdateCount = updateCount; 109 109 WidgetsBinding.instance.addPostFrameCallback((_) { 110 110 if (mounted) { 111 - setState(() { 112 - _loadPost(); 113 - }); 111 + setState(_loadPost); 114 112 } 115 113 }); 116 114 }
+4 -5
lib/src/features/feed/ui/widgets/action_buttons/bookmark_action_button.dart
··· 1 - import 'package:flutter/material.dart'; 2 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 5 5 class BookmarkActionButton extends StatefulWidget { 6 + const BookmarkActionButton({required this.count, super.key, this.isBookmarked = false, this.onPressed}); 6 7 final String count; 7 8 final bool isBookmarked; 8 9 final VoidCallback? onPressed; 9 - 10 - const BookmarkActionButton({super.key, required this.count, this.isBookmarked = false, this.onPressed}); 11 10 12 11 @override 13 12 State<BookmarkActionButton> createState() => _BookmarkActionButtonState(); ··· 26 25 _animationController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); 27 26 28 27 _scaleAnimation = TweenSequence([ 29 - TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.2), weight: 50), 30 - TweenSequenceItem(tween: Tween<double>(begin: 1.2, end: 1.0), weight: 50), 28 + TweenSequenceItem(tween: Tween<double>(begin: 1, end: 1.2), weight: 50), 29 + TweenSequenceItem(tween: Tween<double>(begin: 1.2, end: 1), weight: 50), 31 30 ]).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); 32 31 } 33 32
+4 -5
lib/src/features/feed/ui/widgets/action_buttons/comment_action_button.dart
··· 1 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 2 import 'package:flutter/material.dart'; 3 - import 'package:sparksocial/src/core/widgets/action_button.dart'; 4 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 + import 'package:sparksocial/src/core/widgets/action_button.dart'; 5 5 6 6 class CommentActionButton extends StatelessWidget { 7 - final String count; 8 - final VoidCallback? onPressed; 9 - 10 7 const CommentActionButton({ 11 - super.key, 12 8 required this.count, 9 + super.key, 13 10 this.onPressed, 14 11 }); 12 + final String count; 13 + final VoidCallback? onPressed; 15 14 16 15 @override 17 16 Widget build(BuildContext context) {
+4 -5
lib/src/features/feed/ui/widgets/action_buttons/like_action_button.dart
··· 1 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 4 - import '../../../../../core/theme/data/models/colors.dart'; 4 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 5 5 import 'package:sparksocial/src/core/widgets/action_button.dart'; 6 6 7 7 class LikeActionButton extends StatefulWidget { 8 + const LikeActionButton({required this.count, super.key, this.isLiked = false, this.onPressed}); 8 9 final String count; 9 10 final bool isLiked; 10 11 final VoidCallback? onPressed; 11 - 12 - const LikeActionButton({super.key, required this.count, this.isLiked = false, this.onPressed}); 13 12 14 13 @override 15 14 State<LikeActionButton> createState() => _LikeActionButtonState(); ··· 28 27 _animationController = AnimationController(duration: const Duration(milliseconds: 400), vsync: this); 29 28 30 29 _scaleAnimation = TweenSequence([ 31 - TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.4), weight: 50), 32 - TweenSequenceItem(tween: Tween<double>(begin: 1.4, end: 1.0), weight: 50), 30 + TweenSequenceItem(tween: Tween<double>(begin: 1, end: 1.4), weight: 50), 31 + TweenSequenceItem(tween: Tween<double>(begin: 1.4, end: 1), weight: 50), 33 32 ]).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); 34 33 } 35 34
+28 -28
lib/src/features/feed/ui/widgets/action_buttons/profile_action_button.dart
··· 5 5 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 6 6 7 7 class ProfileActionButton extends StatefulWidget { 8 - final String? profileImageUrl; 9 - final VoidCallback? onPressed; 10 - final VoidCallback? onFollowPressed; 11 - final double size; 12 - final BoxBorder? border; 13 - final bool showFollowButton; 14 - final bool isFollowing; 15 - final double followButtonBottomOffset; 16 - final double followButtonRightOffset; 17 - final double verticalOffset; 18 - final bool debugHitboxes; 19 - 20 8 const ProfileActionButton({ 21 9 super.key, 22 10 this.profileImageUrl, ··· 31 19 this.verticalOffset = 0, 32 20 this.debugHitboxes = false, 33 21 }); 22 + final String? profileImageUrl; 23 + final VoidCallback? onPressed; 24 + final VoidCallback? onFollowPressed; 25 + final double size; 26 + final BoxBorder? border; 27 + final bool showFollowButton; 28 + final bool isFollowing; 29 + final double followButtonBottomOffset; 30 + final double followButtonRightOffset; 31 + final double verticalOffset; 32 + final bool debugHitboxes; 34 33 35 34 @override 36 35 State<ProfileActionButton> createState() => _ProfileActionButtonState(); ··· 60 59 @override 61 60 Widget build(BuildContext context) { 62 61 final followButtonSize = widget.size * 0.5; 63 - final bool hasValidImage = 62 + final hasValidImage = 64 63 widget.profileImageUrl != null && widget.profileImageUrl!.isNotEmpty && !widget.profileImageUrl!.contains('undefined'); 65 64 66 65 return Material( 67 66 color: Colors.transparent, 68 - elevation: 0, 69 67 child: SizedBox( 70 68 width: widget.size, 71 69 height: widget.size, ··· 88 86 border: widget.border ?? Border.all(color: AppColors.white, width: 2), 89 87 ), 90 88 child: ClipOval( 91 - child: 92 - hasValidImage 93 - ? CachedNetworkImage( 94 - imageUrl: widget.profileImageUrl!, 95 - fit: BoxFit.cover, 96 - placeholder: (context, url) => Container(color: AppColors.deepPurple), 97 - errorWidget: 98 - (context, url, error) => Container( 99 - color: AppColors.deepPurple, 100 - child: Center(child: Icon(Icons.person, color: AppColors.white, size: widget.size * 0.5)), 101 - ), 102 - ) 103 - : Container( 89 + child: hasValidImage 90 + ? CachedNetworkImage( 91 + imageUrl: widget.profileImageUrl!, 92 + fit: BoxFit.cover, 93 + placeholder: (context, url) => Container(color: AppColors.deepPurple), 94 + errorWidget: (context, url, error) => ColoredBox( 104 95 color: AppColors.deepPurple, 105 - child: Center(child: Icon(Icons.person, color: AppColors.white, size: widget.size * 0.5)), 96 + child: Center( 97 + child: Icon(Icons.person, color: AppColors.white, size: widget.size * 0.5), 98 + ), 106 99 ), 100 + ) 101 + : ColoredBox( 102 + color: AppColors.deepPurple, 103 + child: Center( 104 + child: Icon(Icons.person, color: AppColors.white, size: widget.size * 0.5), 105 + ), 106 + ), 107 107 ), 108 108 ), 109 109 ),
+2 -3
lib/src/features/feed/ui/widgets/action_buttons/share_action_button.dart
··· 1 - import 'package:flutter/material.dart'; 2 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 3 import 'package:sparksocial/src/core/widgets/action_button.dart'; 4 4 5 5 class ShareActionButton extends StatelessWidget { 6 + const ShareActionButton({required this.count, super.key, this.onPressed}); 6 7 final String count; 7 8 final VoidCallback? onPressed; 8 - 9 - const ShareActionButton({super.key, required this.count, this.onPressed}); 10 9 11 10 @override 12 11 Widget build(BuildContext context) {
+29 -33
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 1 1 import 'package:atproto/core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter/services.dart'; 4 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 - import 'package:auto_route/auto_route.dart'; 6 6 import 'package:get_it/get_it.dart'; 7 7 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 8 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 9 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 10 - 11 10 import 'package:sparksocial/src/core/widgets/menu_action_button.dart'; 12 11 import 'package:sparksocial/src/core/widgets/report_dialog.dart'; 13 12 import 'package:sparksocial/src/features/feed/providers/delete_post.dart'; 14 13 import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 15 - import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/profile_action_button.dart'; 14 + import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/comment_action_button.dart'; 16 15 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/like_action_button.dart'; 17 - import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/comment_action_button.dart'; 16 + import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/profile_action_button.dart'; 18 17 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/share_action_button.dart'; 19 18 20 19 class SideActionBar extends ConsumerStatefulWidget { 21 - final String likeCount; 22 - final String commentCount; 23 - final String shareCount; 24 - final bool isLiked; 25 - final String? profileImageUrl; 26 - final PostView post; 27 - // Add flag to identify image content 28 - final bool isImage; 29 - // Add callback for profile navigation to allow pausing video 30 - final VoidCallback? onProfilePressed; 31 - 32 20 const SideActionBar({ 21 + required this.post, 33 22 super.key, 34 23 this.likeCount = '0', 35 24 this.commentCount = '0', 36 25 this.shareCount = '0', 37 26 this.isLiked = false, 38 27 this.profileImageUrl, 39 - required this.post, 40 28 this.isImage = false, 41 29 this.onProfilePressed, 42 30 }); 31 + final String likeCount; 32 + final String commentCount; 33 + final String shareCount; 34 + final bool isLiked; 35 + final String? profileImageUrl; 36 + final PostView post; 37 + // Add flag to identify image content 38 + final bool isImage; 39 + // Add callback for profile navigation to allow pausing video 40 + final VoidCallback? onProfilePressed; 43 41 44 42 @override 45 43 ConsumerState<SideActionBar> createState() => SideActionBarState(); ··· 111 109 112 110 // Update the post's viewer field to remove the like reference 113 111 final updatedPost = currentPost.copyWith( 114 - viewer: currentPost.viewer?.copyWith(like: null) ?? Viewer(like: null, repost: currentPost.viewer?.repost), 112 + viewer: currentPost.viewer?.copyWith(like: null) ?? Viewer(repost: currentPost.viewer?.repost), 115 113 ); 116 114 117 115 // Update cache with the modified post ··· 136 134 137 135 void _handleShare() { 138 136 final currentPost = _currentPost ?? widget.post; 139 - String postUri = currentPost.uri.toString(); 137 + var postUri = currentPost.uri.toString(); 140 138 String shareUrl; 141 - String embedCode = ''; 142 - bool showEmbed = true; 139 + var embedCode = ''; 140 + var showEmbed = true; 143 141 144 142 // Special case for Bluesky posts 145 143 if (postUri.contains('/app.bsky.feed.post/')) { ··· 297 295 } 298 296 299 297 class SharePanel extends StatefulWidget { 298 + const SharePanel({required this.shareUrl, required this.embedCode, super.key, this.showEmbed = true}); 300 299 final String shareUrl; 301 300 final String embedCode; 302 301 final bool showEmbed; 303 302 304 - const SharePanel({super.key, required this.shareUrl, required this.embedCode, this.showEmbed = true}); 305 - 306 303 @override 307 304 State<SharePanel> createState() => _SharePanelState(); 308 305 } ··· 363 360 decoration: BoxDecoration( 364 361 color: theme.colorScheme.surface, 365 362 borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), 366 - boxShadow: [BoxShadow(color: theme.colorScheme.shadow.withValues(alpha: 0.2), blurRadius: 10, spreadRadius: 0)], 363 + boxShadow: [BoxShadow(color: theme.colorScheme.shadow.withValues(alpha: 0.2), blurRadius: 10)], 367 364 ), 368 365 child: DraggableScrollableSheet( 369 366 initialChildSize: 0.4, ··· 435 432 } 436 433 437 434 class CopyField extends StatelessWidget { 438 - final String text; 439 - final BuildContext context; 440 - final Color bgColor; 441 - final Color textColor; 442 - final bool isLink; 443 - final bool isCopied; 444 - final Function(String, BuildContext, bool) onCopy; 445 - 446 435 const CopyField({ 447 - super.key, 448 436 required this.text, 449 437 required this.context, 450 438 required this.bgColor, ··· 452 440 required this.isLink, 453 441 required this.isCopied, 454 442 required this.onCopy, 443 + super.key, 455 444 }); 445 + final String text; 446 + final BuildContext context; 447 + final Color bgColor; 448 + final Color textColor; 449 + final bool isLink; 450 + final bool isCopied; 451 + final Function(String, BuildContext, bool) onCopy; 456 452 457 453 @override 458 454 Widget build(BuildContext context) { ··· 490 486 return ScaleTransition(scale: animation, child: child); 491 487 }, 492 488 child: isCopied 493 - ? Icon(Icons.check_circle, key: const ValueKey('copied'), color: Colors.green, size: 20) 489 + ? const Icon(Icons.check_circle, key: ValueKey('copied'), color: Colors.green, size: 20) 494 490 : Icon(Icons.content_copy_rounded, key: const ValueKey('copy'), color: accentColor, size: 20), 495 491 ), 496 492 ),
+54 -68
lib/src/features/feed/ui/widgets/feed/cacheable_page_view.dart
··· 26 26 this.clipBehavior = Clip.hardEdge, 27 27 this.scrollBehavior, 28 28 this.padEnds = true, 29 - }) : controller = controller ?? _defaultPageController, 30 - childrenDelegate = SliverChildListDelegate(children); 29 + }) : controller = controller ?? _defaultPageController, 30 + childrenDelegate = SliverChildListDelegate(children); 31 31 32 32 CacheablePageView.builder({ 33 + required NullableIndexedWidgetBuilder itemBuilder, 33 34 super.key, 34 35 this.scrollDirection = Axis.horizontal, 35 36 this.reverse = false, ··· 37 38 this.physics, 38 39 this.pageSnapping = true, 39 40 this.onPageChanged, 40 - required NullableIndexedWidgetBuilder itemBuilder, 41 41 ChildIndexGetter? findChildIndexCallback, 42 42 int? itemCount, 43 43 this.dragStartBehavior = DragStartBehavior.start, ··· 47 47 this.clipBehavior = Clip.hardEdge, 48 48 this.scrollBehavior, 49 49 this.padEnds = true, 50 - }) : controller = controller ?? _defaultPageController, 51 - childrenDelegate = SliverChildBuilderDelegate( 52 - itemBuilder, 53 - findChildIndexCallback: findChildIndexCallback, 54 - childCount: itemCount, 55 - ); 50 + }) : controller = controller ?? _defaultPageController, 51 + childrenDelegate = SliverChildBuilderDelegate( 52 + itemBuilder, 53 + findChildIndexCallback: findChildIndexCallback, 54 + childCount: itemCount, 55 + ); 56 56 57 57 CacheablePageView.custom({ 58 + required this.childrenDelegate, 58 59 super.key, 59 60 this.scrollDirection = Axis.horizontal, 60 61 this.reverse = false, ··· 62 63 this.physics, 63 64 this.pageSnapping = true, 64 65 this.onPageChanged, 65 - required this.childrenDelegate, 66 66 this.dragStartBehavior = DragStartBehavior.start, 67 67 this.cachePageExtent = 0, 68 68 this.allowImplicitScrolling = false, ··· 117 117 switch (widget.scrollDirection) { 118 118 case Axis.horizontal: 119 119 assert(debugCheckHasDirectionality(context)); 120 - final TextDirection textDirection = Directionality.of(context); 121 - final AxisDirection axisDirection = 122 - textDirectionToAxisDirection(textDirection); 123 - return widget.reverse 124 - ? flipAxisDirection(axisDirection) 125 - : axisDirection; 120 + final textDirection = Directionality.of(context); 121 + final axisDirection = textDirectionToAxisDirection(textDirection); 122 + return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection; 126 123 case Axis.vertical: 127 124 return widget.reverse ? AxisDirection.up : AxisDirection.down; 128 125 } ··· 130 127 131 128 @override 132 129 Widget build(BuildContext context) { 133 - final AxisDirection axisDirection = _getDirection(context); 134 - final ScrollPhysics physics = _ForceImplicitScrollPhysics( 135 - allowImplicitScrolling: widget.allowImplicitScrolling, 136 - ).applyTo( 137 - widget.pageSnapping 138 - ? _kPagePhysics.applyTo(widget.physics ?? 139 - widget.scrollBehavior?.getScrollPhysics(context)) 140 - : widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context), 141 - ); 130 + final axisDirection = _getDirection(context); 131 + final ScrollPhysics physics = 132 + _ForceImplicitScrollPhysics( 133 + allowImplicitScrolling: widget.allowImplicitScrolling, 134 + ).applyTo( 135 + widget.pageSnapping 136 + ? _kPagePhysics.applyTo(widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context)) 137 + : widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context), 138 + ); 142 139 143 140 return NotificationListener<ScrollNotification>( 144 141 onNotification: (ScrollNotification notification) { 145 - if (notification.depth == 0 && 146 - widget.onPageChanged != null && 147 - notification is ScrollUpdateNotification) { 148 - final PageMetrics metrics = notification.metrics as PageMetrics; 149 - final int currentPage = metrics.page!.round(); 142 + if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) { 143 + final metrics = notification.metrics as PageMetrics; 144 + final currentPage = metrics.page!.round(); 150 145 if (currentPage != _lastReportedPage) { 151 146 _lastReportedPage = currentPage; 152 147 widget.onPageChanged!(currentPage); ··· 160 155 controller: widget.controller, 161 156 physics: physics, 162 157 restorationId: widget.restorationId, 163 - scrollBehavior: widget.scrollBehavior ?? 164 - ScrollConfiguration.of(context).copyWith(scrollbars: false), 158 + scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false), 165 159 viewportBuilder: (BuildContext context, ViewportOffset position) { 166 160 return LayoutBuilder( 167 - builder: (BuildContext context, BoxConstraints constraints) { 168 - double cacheExtent; 161 + builder: (BuildContext context, BoxConstraints constraints) { 162 + double cacheExtent; 169 163 170 - switch (widget.scrollDirection) { 171 - case Axis.vertical: 172 - cacheExtent = constraints.maxHeight * widget.cachePageExtent; 173 - break; 164 + switch (widget.scrollDirection) { 165 + case Axis.vertical: 166 + cacheExtent = constraints.maxHeight * widget.cachePageExtent; 174 167 175 - case Axis.horizontal: 176 - cacheExtent = constraints.maxWidth * widget.cachePageExtent; 177 - break; 178 - } 168 + case Axis.horizontal: 169 + cacheExtent = constraints.maxWidth * widget.cachePageExtent; 170 + } 179 171 180 - return Viewport( 181 - cacheExtent: cacheExtent, 182 - axisDirection: axisDirection, 183 - offset: position, 184 - slivers: <Widget>[ 185 - SliverFillViewport( 186 - viewportFraction: widget.controller.viewportFraction, 187 - delegate: widget.childrenDelegate, 188 - ), 189 - ], 190 - ); 191 - }); 172 + return Viewport( 173 + cacheExtent: cacheExtent, 174 + axisDirection: axisDirection, 175 + offset: position, 176 + slivers: <Widget>[ 177 + SliverFillViewport( 178 + viewportFraction: widget.controller.viewportFraction, 179 + delegate: widget.childrenDelegate, 180 + ), 181 + ], 182 + ); 183 + }, 184 + ); 192 185 }, 193 186 ), 194 187 ); ··· 197 190 @override 198 191 void debugFillProperties(DiagnosticPropertiesBuilder description) { 199 192 super.debugFillProperties(description); 200 - description 201 - .add(EnumProperty<Axis>('scrollDirection', widget.scrollDirection)); 193 + description.add(EnumProperty<Axis>('scrollDirection', widget.scrollDirection)); 194 + description.add(FlagProperty('reverse', value: widget.reverse, ifTrue: 'reversed')); 195 + description.add(DiagnosticsProperty<PageController>('controller', widget.controller, showName: false)); 196 + description.add(DiagnosticsProperty<ScrollPhysics>('physics', widget.physics, showName: false)); 197 + description.add(FlagProperty('pageSnapping', value: widget.pageSnapping, ifFalse: 'snapping disabled')); 202 198 description.add( 203 - FlagProperty('reverse', value: widget.reverse, ifTrue: 'reversed')); 204 - description.add(DiagnosticsProperty<PageController>( 205 - 'controller', widget.controller, 206 - showName: false)); 207 - description.add(DiagnosticsProperty<ScrollPhysics>( 208 - 'physics', widget.physics, 209 - showName: false)); 210 - description.add(FlagProperty('pageSnapping', 211 - value: widget.pageSnapping, ifFalse: 'snapping disabled')); 212 - description.add(FlagProperty('allowImplicitScrolling', 213 - value: widget.allowImplicitScrolling, 214 - ifTrue: 'allow implicit scrolling')); 199 + FlagProperty('allowImplicitScrolling', value: widget.allowImplicitScrolling, ifTrue: 'allow implicit scrolling'), 200 + ); 215 201 } 216 202 } 217 203
+16 -10
lib/src/features/feed/ui/widgets/feed/feeds_bar.dart
··· 1 + import 'dart:ui' show lerpDouble; 2 + 3 + import 'package:auto_route/auto_route.dart'; 4 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 1 5 import 'package:flutter/material.dart'; 2 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 7 import 'package:get_it/get_it.dart'; 4 - import 'dart:ui' show lerpDouble; 8 + import 'package:sparksocial/src/core/routing/app_router.dart'; 5 9 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 10 + import 'package:sparksocial/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 6 11 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 7 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 8 - import 'package:auto_route/auto_route.dart'; 9 - import 'package:sparksocial/src/core/routing/app_router.dart'; 10 12 11 13 class FeedsBar extends ConsumerStatefulWidget implements PreferredSizeWidget { 12 - const FeedsBar({super.key, required this.pageController}); 14 + const FeedsBar({required this.pageController, super.key}); 13 15 14 16 final PageController pageController; 15 17 ··· 70 72 builder: (context, constraints) { 71 73 final availableWidth = constraints.maxWidth; 72 74 // Account for the leading widget (back button) - approximately 56px 73 - final leadingWidth = 56.0; 75 + const leadingWidth = 56.0; 74 76 final centeringWidth = availableWidth - leadingWidth; 75 77 final horizontalPadding = leadingWidth + (centeringWidth - totalWidth) / 2.0; 76 78 ··· 140 142 onTap: _isReordering 141 143 ? null 142 144 : () { 143 - ref.read(settingsProvider.notifier).setActiveFeed(feed); 144 - final feedIndex = settings.feeds.indexOf(feed); 145 - if (feedIndex != -1 && widget.pageController.hasClients) { 146 - widget.pageController.jumpToPage(feedIndex); 145 + if (settings.activeFeed == feed) { 146 + ref.read(feedRefreshTriggerProvider(feed).notifier).trigger(); 147 + } else { 148 + ref.read(settingsProvider.notifier).setActiveFeed(feed); 149 + final feedIndex = settings.feeds.indexOf(feed); 150 + if (feedIndex != -1 && widget.pageController.hasClients) { 151 + widget.pageController.jumpToPage(feedIndex); 152 + } 147 153 } 148 154 }, 149 155 borderRadius: BorderRadius.circular(25),
+6 -10
lib/src/features/feed/ui/widgets/images/image_carousel.dart
··· 6 6 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 7 8 8 class ImageCarousel extends ConsumerStatefulWidget { 9 - const ImageCarousel({super.key, required this.imageUrls, this.alts}); 9 + const ImageCarousel({required this.imageUrls, super.key, this.alts}); 10 10 final List<String> imageUrls; 11 11 final List<String>? alts; 12 12 ··· 35 35 return Stack( 36 36 children: [ 37 37 DecoratedBox( 38 - decoration: BoxDecoration(color: AppColors.black), 38 + decoration: const BoxDecoration(color: AppColors.black), 39 39 child: CachedNetworkImage( 40 40 imageUrl: widget.imageUrls[realIndex], 41 41 fit: BoxFit.contain, ··· 50 50 ); 51 51 }, 52 52 options: CarouselOptions( 53 - initialPage: 0, 54 - pageSnapping: true, 55 - scrollDirection: Axis.horizontal, 56 53 aspectRatio: 0.5, 57 54 height: MediaQuery.of(context).size.height, 58 55 viewportFraction: 1, ··· 82 79 ), 83 80 ), 84 81 ), 85 - 82 + 86 83 Positioned( 87 84 bottom: 10, 88 85 left: 0, 89 86 right: 0, 90 87 child: Row( 91 88 mainAxisAlignment: MainAxisAlignment.center, 92 - crossAxisAlignment: CrossAxisAlignment.center, 93 89 children: [ 94 90 ...List.generate( 95 91 widget.imageUrls.length, 96 92 (index) => Container( 97 - width: 8.0, 98 - height: 8.0, 99 - margin: const EdgeInsets.symmetric(horizontal: 4.0), 93 + width: 8, 94 + height: 8, 95 + margin: const EdgeInsets.symmetric(horizontal: 4), 100 96 decoration: BoxDecoration( 101 97 shape: BoxShape.circle, 102 98 color: currentIndex == index ? Colors.white : Colors.white.withAlpha(128),
+1 -2
lib/src/features/feed/ui/widgets/post/alt_text_dialog.dart
··· 2 2 3 3 /// A dialog that displays the alt text (image description) for an image. 4 4 class AltTextDialog extends StatelessWidget { 5 + const AltTextDialog({required this.altText, super.key}); 5 6 final String altText; 6 - 7 - const AltTextDialog({super.key, required this.altText}); 8 7 9 8 @override 10 9 Widget build(BuildContext context) {
+3 -4
lib/src/features/feed/ui/widgets/post/description.dart
··· 2 2 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 3 4 4 class Description extends StatefulWidget { 5 + const Description({required this.text, super.key, this.style, this.maxLines = 2, this.onExpandToggle}); 5 6 final String text; 6 7 final TextStyle? style; 7 8 final int maxLines; 8 9 final Function(bool isExpanded)? onExpandToggle; 9 - 10 - const Description({super.key, required this.text, this.style, this.maxLines = 2, this.onExpandToggle}); 11 10 12 11 @override 13 12 State<Description> createState() => _DescriptionState(); ··· 24 23 _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); 25 24 26 25 _scaleAnimation = TweenSequence<double>([ 27 - TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.03), weight: 30), 28 - TweenSequenceItem(tween: Tween<double>(begin: 1.03, end: 1.0), weight: 70), 26 + TweenSequenceItem(tween: Tween<double>(begin: 1, end: 1.03), weight: 30), 27 + TweenSequenceItem(tween: Tween<double>(begin: 1.03, end: 1), weight: 70), 29 28 ]).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); 30 29 } 31 30
+16 -18
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 1 - import 'package:auto_route/auto_route.dart'; 2 1 import 'package:atproto/atproto.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:get_it/get_it.dart'; 6 6 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 7 + import 'package:sparksocial/src/core/routing/app_router.dart'; 7 8 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 8 9 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 10 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 11 + import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 12 + import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 9 13 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 10 - import 'package:sparksocial/src/features/feed/providers/post_updates.dart'; 11 14 import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 15 + import 'package:sparksocial/src/features/feed/providers/post_updates.dart'; 12 16 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 13 - import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 14 17 import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 18 + import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 15 19 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 16 20 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 17 - import 'package:sparksocial/src/core/routing/app_router.dart'; 18 - import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 19 - import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 20 - import 'package:sparksocial/src/core/utils/label_utils.dart'; 21 21 22 22 class FeedPostWidget extends ConsumerStatefulWidget { 23 - const FeedPostWidget({super.key, required this.index, required this.feed}); 23 + const FeedPostWidget({required this.index, required this.feed, super.key}); 24 24 25 25 final int index; 26 26 final Feed feed; ··· 103 103 if (widget.index < feedState.loadedPosts.length) { 104 104 final uri = feedState.loadedPosts[widget.index]; 105 105 final extraInfo = feedState.extraInfo[uri]; 106 - 106 + 107 107 if (extraInfo != null && extraInfo.postLabels.isNotEmpty && !_userDismissedWarning) { 108 108 final shouldShowWarning = await LabelUtils.shouldShowWarning(extraInfo.postLabels); 109 109 if (shouldShowWarning) { ··· 147 147 _lastUpdateCount = updateCount; 148 148 WidgetsBinding.instance.addPostFrameCallback((_) { 149 149 if (mounted) { 150 - setState(() { 151 - _loadPost(); 152 - }); 150 + setState(_loadPost); 153 151 _checkContentWarning(currentUri); 154 152 } 155 153 }); ··· 251 249 child: Builder( 252 250 builder: (context) { 253 251 final feedState = ref.read(feedNotifierProvider(widget.feed)); 254 - List<Label> labels = []; 255 - 252 + var labels = <Label>[]; 253 + 256 254 if (widget.index < feedState.loadedPosts.length) { 257 255 final uri = feedState.loadedPosts[widget.index]; 258 256 final extraInfo = feedState.extraInfo[uri]; ··· 260 258 labels = extraInfo.postLabels; 261 259 } 262 260 } 263 - 261 + 264 262 return FutureBuilder<List<String>>( 265 263 future: LabelUtils.getInformLabels(labels), 266 264 builder: (context, snapshot) { ··· 305 303 } 306 304 if (snapshot.hasError) { 307 305 return DecoratedBox( 308 - decoration: BoxDecoration(color: AppColors.black), 306 + decoration: const BoxDecoration(color: AppColors.black), 309 307 child: Center( 310 308 child: Text('Error loading post: ${snapshot.error}', style: const TextStyle(color: Colors.white)), 311 309 ), 312 310 ); 313 311 } 314 - return DecoratedBox( 312 + return const DecoratedBox( 315 313 decoration: BoxDecoration(color: AppColors.black), 316 - child: const Center(child: CircularProgressIndicator(color: AppColors.white)), 314 + child: Center(child: CircularProgressIndicator(color: AppColors.white)), 317 315 ); 318 316 }, 319 317 );
+15 -19
lib/src/features/feed/ui/widgets/post/hashtag_list.dart
··· 3 3 import 'package:sparksocial/src/core/widgets/fading_list_view.dart'; 4 4 5 5 class HashtagList extends StatelessWidget { 6 + const HashtagList({required this.hashtags, super.key, this.style, this.onHashtagTap}); 6 7 final List<String> hashtags; 7 8 final TextStyle? style; 8 9 final Function(String)? onHashtagTap; 9 - 10 - const HashtagList({super.key, required this.hashtags, this.style, this.onHashtagTap}); 11 10 12 11 @override 13 12 Widget build(BuildContext context) { ··· 16 15 } 17 16 18 17 return FadingListView( 19 - isHorizontal: true, 20 - fadeWidth: 16.0, 21 - itemSpacing: 8.0, 22 - children: 23 - hashtags.map((tag) { 24 - return GestureDetector( 25 - onTap: onHashtagTap != null ? () => onHashtagTap!(tag) : null, 26 - child: Container( 27 - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), 28 - decoration: BoxDecoration(color: AppColors.white.withAlpha(50), borderRadius: BorderRadius.circular(12.0)), 29 - child: Text( 30 - '#$tag', 31 - style: style ?? const TextStyle(color: AppColors.white, fontWeight: FontWeight.w500, fontSize: 13), 32 - ), 33 - ), 34 - ); 35 - }).toList(), 18 + fadeWidth: 16, 19 + children: hashtags.map((tag) { 20 + return GestureDetector( 21 + onTap: onHashtagTap != null ? () => onHashtagTap!(tag) : null, 22 + child: Container( 23 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 24 + decoration: BoxDecoration(color: AppColors.white.withAlpha(50), borderRadius: BorderRadius.circular(12)), 25 + child: Text( 26 + '#$tag', 27 + style: style ?? const TextStyle(color: AppColors.white, fontWeight: FontWeight.w500, fontSize: 13), 28 + ), 29 + ), 30 + ); 31 + }).toList(), 36 32 ); 37 33 } 38 34 }
+36 -30
lib/src/features/feed/ui/widgets/post/info_bar.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 import 'package:sparksocial/src/features/feed/ui/widgets/post/alt_text_dialog.dart'; 5 - 6 - import 'hashtag_list.dart'; 7 - import 'post_source.dart'; 8 - import 'description.dart'; 5 + import 'package:sparksocial/src/features/feed/ui/widgets/post/description.dart'; 6 + import 'package:sparksocial/src/features/feed/ui/widgets/post/hashtag_list.dart'; 7 + import 'package:sparksocial/src/features/feed/ui/widgets/post/post_source.dart'; 9 8 10 9 class InfoBar extends StatelessWidget { 11 - final String username; 12 - final String description; 13 - final List<String> hashtags; 14 - final List<String> informLabels; 15 - final bool isSprk; 16 - final String? altText; 17 - final VoidCallback? onUsernameTap; 18 - final Function(String)? onHashtagTap; 19 - final Function(bool isExpanded)? onDescriptionExpandToggle; 20 - 21 10 const InfoBar({ 22 - super.key, 23 11 required this.username, 24 12 required this.description, 25 13 required this.hashtags, 14 + super.key, 26 15 this.informLabels = const [], 27 16 this.isSprk = false, 28 17 this.altText, ··· 30 19 this.onHashtagTap, 31 20 this.onDescriptionExpandToggle, 32 21 }); 22 + final String username; 23 + final String description; 24 + final List<String> hashtags; 25 + final List<String> informLabels; 26 + final bool isSprk; 27 + final String? altText; 28 + final VoidCallback? onUsernameTap; 29 + final Function(String)? onHashtagTap; 30 + final Function(bool isExpanded)? onDescriptionExpandToggle; 33 31 34 32 @override 35 33 Widget build(BuildContext context) { ··· 41 39 crossAxisAlignment: CrossAxisAlignment.start, 42 40 children: [ 43 41 Row( 44 - crossAxisAlignment: CrossAxisAlignment.center, 45 42 children: [ 46 - Expanded(child: GestureDetector(onTap: onUsernameTap, child: PostSource(username: username, isSprk: isSprk))), 43 + Expanded( 44 + child: GestureDetector( 45 + onTap: onUsernameTap, 46 + child: PostSource(username: username, isSprk: isSprk), 47 + ), 48 + ), 47 49 if (altText != null && altText!.trim().isNotEmpty) _AltButton(altText: altText!), 48 50 ], 49 51 ), ··· 54 56 55 57 if (hasDescription && hasHashtags) const SizedBox(height: 6), 56 58 57 - if (hasHashtags) SizedBox(height: 25, child: HashtagList(hashtags: hashtags, onHashtagTap: onHashtagTap)), 59 + if (hasHashtags) 60 + SizedBox( 61 + height: 25, 62 + child: HashtagList(hashtags: hashtags, onHashtagTap: onHashtagTap), 63 + ), 58 64 59 65 if (hasInformLabels && (hasHashtags || hasDescription)) const SizedBox(height: 6), 60 66 ··· 65 71 } 66 72 67 73 class _AltButton extends StatelessWidget { 74 + const _AltButton({required this.altText}); 68 75 final String altText; 69 - 70 - const _AltButton({required this.altText}); 71 76 72 77 @override 73 78 Widget build(BuildContext context) { ··· 84 89 builder: (_) => AltTextDialog(altText: altText), 85 90 ); 86 91 }, 87 - child: Padding( 88 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), 92 + child: const Padding( 93 + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), 89 94 child: Row( 90 95 mainAxisSize: MainAxisSize.min, 91 - children: const [ 96 + children: [ 92 97 Icon(FluentIcons.image_alt_text_20_regular, color: AppColors.white, size: 18), 93 98 SizedBox(width: 6), 94 - Text('ALT', style: TextStyle(color: AppColors.white, fontWeight: FontWeight.bold, fontSize: 12)), 99 + Text( 100 + 'ALT', 101 + style: TextStyle(color: AppColors.white, fontWeight: FontWeight.bold, fontSize: 12), 102 + ), 95 103 ], 96 104 ), 97 105 ), ··· 101 109 } 102 110 103 111 class _InformLabels extends StatelessWidget { 104 - final List<String> labels; 105 - 106 112 const _InformLabels({required this.labels}); 113 + final List<String> labels; 107 114 108 115 @override 109 116 Widget build(BuildContext context) { ··· 116 123 } 117 124 118 125 class _InformLabelChip extends StatelessWidget { 126 + const _InformLabelChip({required this.label}); 119 127 final String label; 120 - 121 - const _InformLabelChip({required this.label}); 122 128 123 129 @override 124 130 Widget build(BuildContext context) { ··· 127 133 decoration: BoxDecoration( 128 134 color: AppColors.blue.withAlpha(150), 129 135 borderRadius: BorderRadius.circular(12), 130 - border: Border.all(color: AppColors.blue.withAlpha(100), width: 1), 136 + border: Border.all(color: AppColors.blue.withAlpha(100)), 131 137 ), 132 138 child: Row( 133 139 mainAxisSize: MainAxisSize.min, 134 140 children: [ 135 - Icon( 141 + const Icon( 136 142 FluentIcons.info_16_regular, 137 143 color: AppColors.white, 138 144 size: 12,
+4 -2
lib/src/features/feed/ui/widgets/post/no_more_posts.dart
··· 6 6 7 7 @override 8 8 Widget build(BuildContext context) { 9 - return DecoratedBox( 9 + return const DecoratedBox( 10 10 decoration: BoxDecoration(color: AppColors.black), 11 - child: const Center(child: Text('No more posts in this feed.', style: TextStyle(color: AppColors.white))), 11 + child: Center( 12 + child: Text('No more posts in this feed.', style: TextStyle(color: AppColors.white)), 13 + ), 12 14 ); 13 15 } 14 16 }
+2 -3
lib/src/features/feed/ui/widgets/post/post_source.dart
··· 3 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 5 5 class PostSource extends StatelessWidget { 6 + const PostSource({required this.username, super.key, this.isSprk = false}); 6 7 final String username; 7 8 final bool isSprk; 8 - 9 - const PostSource({super.key, required this.username, this.isSprk = false}); 10 9 11 10 @override 12 11 Widget build(BuildContext context) { ··· 26 25 decoration: BoxDecoration( 27 26 borderRadius: BorderRadius.circular(42), 28 27 boxShadow: [ 29 - BoxShadow(color: AppColors.black.withAlpha(30), blurRadius: 4, spreadRadius: 1, offset: const Offset(0, 0)), 28 + BoxShadow(color: AppColors.black.withAlpha(30), blurRadius: 4, spreadRadius: 1), 30 29 ], 31 30 ), 32 31 child: isSprk
+19 -24
lib/src/features/feed/ui/widgets/videos/slider.dart
··· 37 37 // 1. Calculate the visual track rectangle, aligned to the bottom. 38 38 // We use the `getPreferredRect` from the superclass to get the correct 39 39 // horizontal dimensions and offsets. 40 - final Rect preferredRect = super.getPreferredRect( 40 + final preferredRect = super.getPreferredRect( 41 41 parentBox: parentBox, 42 42 offset: offset, 43 43 sliderTheme: sliderTheme, ··· 45 45 isDiscrete: isDiscrete, 46 46 ); 47 47 48 - final double trackHeight = sliderTheme.trackHeight!; 48 + final trackHeight = sliderTheme.trackHeight!; 49 49 // The visual track is at the bottom of the parentBox. 50 50 // We subtract the track height to get the top of our visual track. 51 - final Rect trackRect = Rect.fromLTRB( 51 + final trackRect = Rect.fromLTRB( 52 52 preferredRect.left, 53 53 parentBox.size.height - trackHeight, 54 54 preferredRect.right, ··· 58 58 // 2. Adjust the thumb's vertical position to match our new visual track. 59 59 // The incoming `thumbCenter` is for the centered hitbox, so we create a 60 60 // new Offset for painting. 61 - final Offset adjustedThumbCenter = Offset( 61 + final adjustedThumbCenter = Offset( 62 62 thumbCenter.dx, 63 63 trackRect.center.dy, // Center of our bottom-aligned track 64 64 ); 65 65 66 66 // 3. Adjust the secondary offset's vertical position as well. 67 - final Offset? adjustedSecondaryOffset = secondaryOffset != null 68 - ? Offset(secondaryOffset.dx, trackRect.center.dy) 69 - : null; 67 + final adjustedSecondaryOffset = secondaryOffset != null ? Offset(secondaryOffset.dx, trackRect.center.dy) : null; 70 68 71 69 // --- End of Custom Logic --- 72 - 73 70 74 71 // The rest of this method is a copy of the original 75 72 // `RoundedRectSliderTrackShape.paint` method, but it uses our 76 73 // new `trackRect`, `adjustedThumbCenter`, and `adjustedSecondaryOffset` values. 77 74 78 - final ColorTween activeTrackColorTween = ColorTween( 75 + final activeTrackColorTween = ColorTween( 79 76 begin: sliderTheme.disabledActiveTrackColor, 80 77 end: sliderTheme.activeTrackColor, 81 78 ); 82 - final ColorTween inactiveTrackColorTween = ColorTween( 79 + final inactiveTrackColorTween = ColorTween( 83 80 begin: sliderTheme.disabledInactiveTrackColor, 84 81 end: sliderTheme.inactiveTrackColor, 85 82 ); 86 - final Paint activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; 87 - final Paint inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; 83 + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; 84 + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; 88 85 final (Paint leftTrackPaint, Paint rightTrackPaint) = switch (textDirection) { 89 86 TextDirection.ltr => (activePaint, inactivePaint), 90 87 TextDirection.rtl => (inactivePaint, activePaint), 91 88 }; 92 89 93 - final Radius trackRadius = Radius.circular(trackRect.height / 2); 94 - final Radius activeTrackRadius = Radius.circular( 90 + final trackRadius = Radius.circular(trackRect.height / 2); 91 + final activeTrackRadius = Radius.circular( 95 92 (trackRect.height + additionalActiveTrackHeight) / 2, 96 93 ); 97 - final bool isLTR = textDirection == TextDirection.ltr; 98 - final bool isRTL = textDirection == TextDirection.rtl; 94 + final isLTR = textDirection == TextDirection.ltr; 95 + final isRTL = textDirection == TextDirection.rtl; 99 96 100 - final bool drawInactiveTrack = 101 - adjustedThumbCenter.dx < (trackRect.right - (sliderTheme.trackHeight! / 2)); 97 + final drawInactiveTrack = adjustedThumbCenter.dx < (trackRect.right - (sliderTheme.trackHeight! / 2)); 102 98 if (drawInactiveTrack) { 103 99 // Draw the inactive track segment. 104 100 context.canvas.drawRRect( ··· 112 108 rightTrackPaint, 113 109 ); 114 110 } 115 - final bool drawActiveTrack = adjustedThumbCenter.dx > (trackRect.left + (sliderTheme.trackHeight! / 2)); 111 + final drawActiveTrack = adjustedThumbCenter.dx > (trackRect.left + (sliderTheme.trackHeight! / 2)); 116 112 if (drawActiveTrack) { 117 113 // Draw the active track segment. 118 114 context.canvas.drawRRect( ··· 127 123 ); 128 124 } 129 125 130 - final bool showSecondaryTrack = 126 + final showSecondaryTrack = 131 127 (adjustedSecondaryOffset != null) && 132 128 (isLTR ? (adjustedSecondaryOffset.dx > adjustedThumbCenter.dx) : (adjustedSecondaryOffset.dx < adjustedThumbCenter.dx)); 133 129 134 130 if (showSecondaryTrack) { 135 - final ColorTween secondaryTrackColorTween = ColorTween( 131 + final secondaryTrackColorTween = ColorTween( 136 132 begin: sliderTheme.disabledSecondaryActiveTrackColor, 137 133 end: sliderTheme.secondaryActiveTrackColor, 138 134 ); 139 - final Paint secondaryTrackPaint = 140 - Paint()..color = secondaryTrackColorTween.evaluate(enableAnimation)!; 135 + final secondaryTrackPaint = Paint()..color = secondaryTrackColorTween.evaluate(enableAnimation)!; 141 136 if (isLTR) { 142 137 context.canvas.drawRRect( 143 138 RRect.fromLTRBAndCorners( ··· 165 160 } 166 161 } 167 162 } 168 - } 163 + }
+4 -5
lib/src/features/feed/ui/widgets/videos/time_display.dart
··· 2 2 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 3 4 4 class TimeDisplay extends StatelessWidget { 5 + const TimeDisplay({required this.position, required this.duration, super.key}); 5 6 final Duration position; 6 7 final Duration duration; 7 - 8 - const TimeDisplay({super.key, required this.position, required this.duration}); 9 8 10 9 String _formatDuration(Duration duration) { 11 10 final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); ··· 19 18 child: Container( 20 19 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 21 20 decoration: BoxDecoration( 22 - color: AppColors.black.withAlpha(180), 21 + color: AppColors.black.withAlpha(180), 23 22 borderRadius: BorderRadius.circular(12), 24 23 boxShadow: [ 25 24 BoxShadow( ··· 32 31 child: Text( 33 32 '${_formatDuration(position)} / ${_formatDuration(duration)}', 34 33 style: const TextStyle( 35 - color: AppColors.white, 36 - fontWeight: FontWeight.bold, 34 + color: AppColors.white, 35 + fontWeight: FontWeight.bold, 37 36 fontSize: 16, 38 37 letterSpacing: 0.5, 39 38 ),
+8 -9
lib/src/features/feed/ui/widgets/videos/video_player.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 5 import 'package:get_it/get_it.dart'; ··· 7 9 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 8 10 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 9 11 import 'package:sparksocial/src/features/feed/ui/widgets/videos/slider.dart'; 10 - import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 11 12 import 'package:sparksocial/src/features/feed/ui/widgets/videos/time_display.dart'; 13 + import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 12 14 import 'package:video_player/video_player.dart'; 13 - import 'dart:async'; 14 15 15 16 class PostVideoPlayer extends ConsumerStatefulWidget { 16 - const PostVideoPlayer({super.key, required this.videoUrl, this.feed, this.index, required this.isSparkPost}); 17 + const PostVideoPlayer({required this.videoUrl, required this.isSparkPost, super.key, this.feed, this.index}); 17 18 18 19 final String videoUrl; 19 20 final Feed? feed; ··· 58 59 super.initState(); 59 60 _bounceController = AnimationController(duration: const Duration(milliseconds: 300), vsync: this); 60 61 _bounceAnimation = Tween<double>( 61 - begin: 1.0, 62 + begin: 1, 62 63 end: 1.3, 63 64 ).animate(CurvedAnimation(parent: _bounceController, curve: Curves.elasticOut)); 64 65 initVideoPlayer(); ··· 170 171 if (!isInitialized) return; 171 172 _isSeeking = true; 172 173 videoController.pause(); 173 - videoController.setVolume(0.0); 174 + videoController.setVolume(0); 174 175 } 175 176 176 177 void _onSeekChanged(double value) { ··· 184 185 void _onSeekEnd(double value) { 185 186 if (!isInitialized) return; 186 187 _isSeeking = false; 187 - videoController.setVolume(1.0); 188 + videoController.setVolume(1); 188 189 videoController.play(); 189 190 } 190 191 ··· 217 218 _lastFeedIndex = feedState.index; 218 219 WidgetsBinding.instance.addPostFrameCallback((_) { 219 220 if (mounted && !_userInteracted) { 220 - bool shouldPlay = feedState.index == widget.index && isOnFeedsTab; 221 + final shouldPlay = feedState.index == widget.index && isOnFeedsTab; 221 222 _handleAutoPlayPause(shouldPlay); 222 223 } 223 224 }); ··· 252 253 children: [ 253 254 SizedBox.expand( 254 255 child: FittedBox( 255 - fit: BoxFit.contain, 256 256 child: SizedBox( 257 257 width: videoController.value.size.width, 258 258 height: videoController.value.size.height, ··· 326 326 ), 327 327 child: Slider( 328 328 value: position.inMilliseconds.toDouble(), 329 - min: 0, 330 329 max: duration.inMilliseconds.toDouble(), 331 330 onChanged: _onSeekChanged, 332 331 onChangeStart: _onSeekStart,
+11 -12
lib/src/features/home/ui/pages/main_page.dart
··· 1 - // ignore_for_file: dead_code 2 - 3 1 import 'package:auto_route/auto_route.dart'; 4 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 3 import 'package:flutter/material.dart'; ··· 7 5 8 6 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 7 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 8 + import 'package:sparksocial/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 10 9 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 10 + import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 11 11 12 12 @RoutePage() 13 13 class MainPage extends ConsumerStatefulWidget { ··· 27 27 Widget build(BuildContext context) { 28 28 return AutoTabsRouter( 29 29 key: const ValueKey('mainTabsRouter'), 30 - routes: [ 31 - const FeedsRoute(), 32 - const SearchRoute(), 33 - const EmptyRoute(), 34 - const MessagesRoute(), 35 - UserProfileRoute() 36 - ], 30 + routes: const [FeedsRoute(), SearchRoute(), EmptyRoute(), MessagesRoute(), UserProfileRoute()], 37 31 transitionBuilder: (context, child, animation) => child, 38 32 builder: (context, child) { 39 33 final tabsRouter = AutoTabsRouter.of(context); ··· 46 40 onDestinationSelected: (index) { 47 41 if (index == 2) { 48 42 // Special case for Create button - navigate to create video page 49 - context.router.push(CreateVideoRoute(isStoryMode: false)); 43 + context.router.push(CreateVideoRoute()); 50 44 } else { 51 - tabsRouter.setActiveIndex(index); 52 - ref.read(navigationProvider.notifier).updateIndex(index); 45 + if (tabsRouter.activeIndex == index && index == 0) { 46 + final activeFeed = ref.read(settingsProvider).activeFeed; 47 + ref.read(feedRefreshTriggerProvider(activeFeed).notifier).trigger(); 48 + } else { 49 + tabsRouter.setActiveIndex(index); 50 + ref.read(navigationProvider.notifier).updateIndex(index); 51 + } 53 52 } 54 53 }, 55 54 destinations: [
+6 -5
lib/src/features/messages/providers/conversation_provider.dart
··· 22 22 Future<Message> sendMessage(String otherDid, String message, {List<Embed>? embed, String? currentUserDid}) async { 23 23 final other = state.value?.other ?? await GetIt.I<SprkRepository>().actor.getProfile(otherDid); 24 24 final messages = state.value?.messages ?? []; 25 - 25 + 26 26 try { 27 27 // Send message to server and get the actual result 28 28 final sentMessage = await GetIt.I<MessagesRepository>().sendMessage(otherDid, message, embed: embed); 29 - 29 + 30 30 // Update state with the new message 31 31 state = AsyncValue.data( 32 32 ConversationState(other, [...messages, sentMessage]), 33 33 ); 34 - 34 + 35 35 return sentMessage; 36 36 } catch (e) { 37 37 // If sending fails, keep the current state and rethrow ··· 44 44 final otherDid = state.value!.other.did; 45 45 final (cursor: _, messages: newBatch) = await GetIt.I<MessagesRepository>().getConversation(otherDid, cursor: cursor); 46 46 final newestMessage = newBatch.isNotEmpty ? newBatch.last : null; 47 - if (newestMessage != null && (state.value!.messages.isEmpty || newestMessage.timestamp.compareTo(state.value!.messages.last.timestamp) > 0)) { 47 + if (newestMessage != null && 48 + (state.value!.messages.isEmpty || newestMessage.timestamp.compareTo(state.value!.messages.last.timestamp) > 0)) { 48 49 // only new messages from the new batch 49 50 final newMessages = newBatch.where((msg) => !state.value!.messages.any((m) => m.id == msg.id)).toList(); 50 51 final updatedMessages = [...state.value!.messages, ...newMessages]; 51 52 state = AsyncValue.data(ConversationState(state.value!.other, updatedMessages)); 52 - } 53 + } 53 54 } 54 55 55 56 // TODO: loadmore
+1 -3
lib/src/features/messages/providers/polling_timer.dart
··· 12 12 ref.read(conversationProvider(otherDid).notifier).checkForNewMessages(); 13 13 }); 14 14 15 - ref.onDispose(() { 16 - timer.cancel(); 17 - }); 15 + ref.onDispose(timer.cancel); 18 16 }
+4 -6
lib/src/features/messages/ui/pages/chat_page.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:auto_route/auto_route.dart'; 4 - import 'package:flutter/material.dart'; 5 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 + import 'package:flutter/material.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 7 import 'package:image_picker/image_picker.dart'; 8 8 import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; ··· 17 17 18 18 @RoutePage() 19 19 class ChatPage extends ConsumerStatefulWidget { 20 + const ChatPage({required this.otherUserDid, super.key, this.otherUserHandle, this.otherUserDisplayName, this.otherUserAvatar}); 20 21 final String otherUserDid; 21 22 final String? otherUserHandle; 22 23 final String? otherUserDisplayName; 23 24 final String? otherUserAvatar; 24 - 25 - const ChatPage({super.key, required this.otherUserDid, this.otherUserHandle, this.otherUserDisplayName, this.otherUserAvatar}); 26 25 27 26 @override 28 27 ConsumerState<ChatPage> createState() => _ChatPageState(); ··· 52 51 final urlRegex = RegExp( 53 52 r'https?://(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*|www\.[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*', 54 53 caseSensitive: false, 55 - multiLine: false, 56 54 ); 57 55 return urlRegex.allMatches(text).map((match) => match.group(0)!).toList(); 58 56 } 59 57 60 58 Future<void> _sendMessage() async { 61 - String content = _messageController.text.trim(); 59 + var content = _messageController.text.trim(); 62 60 final links = _extractLinks(content); 63 61 64 62 // remove links from content ··· 84 82 _scrollToBottom(); 85 83 } catch (e) { 86 84 if (mounted) { 87 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to send message: ${e.toString()}'))); 85 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to send message: $e'))); 88 86 } 89 87 } 90 88 }
+4 -7
lib/src/features/messages/ui/pages/messages_page.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:get_it/get_it.dart'; 6 + import 'package:sparksocial/src/core/routing/app_router.dart'; 6 7 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 8 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 8 - import 'package:sparksocial/src/core/routing/app_router.dart'; 9 9 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 10 10 import 'package:sparksocial/src/features/messages/providers/conversations_provider.dart'; 11 11 ··· 98 98 } 99 99 100 100 class CustomTabBar extends StatelessWidget { 101 + const CustomTabBar({required this.selectedTabIndex, required this.onTabChanged, super.key}); 101 102 final int selectedTabIndex; 102 103 final Function(int) onTabChanged; 103 - 104 - const CustomTabBar({super.key, required this.selectedTabIndex, required this.onTabChanged}); 105 104 106 105 @override 107 106 Widget build(BuildContext context) { ··· 122 121 } 123 122 124 123 class TabItem extends StatelessWidget { 124 + const TabItem({required this.isSelected, required this.label, required this.onTap, super.key}); 125 125 final bool isSelected; 126 126 final String label; 127 127 final VoidCallback onTap; 128 - 129 - const TabItem({super.key, required this.isSelected, required this.label, required this.onTap}); 130 128 131 129 @override 132 130 Widget build(BuildContext context) { ··· 165 163 } 166 164 167 165 class MessagesTab extends ConsumerWidget { 166 + const MessagesTab({required this.onRefresh, super.key}); 168 167 final VoidCallback onRefresh; 169 - 170 - const MessagesTab({super.key, required this.onRefresh}); 171 168 172 169 @override 173 170 Widget build(BuildContext context, WidgetRef ref) {
+6 -6
lib/src/features/messages/ui/pages/new_chat_search_page.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 5 6 import 'package:sparksocial/src/core/routing/app_router.dart'; 6 7 import 'package:sparksocial/src/features/search/providers/search_provider.dart'; 7 8 import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; 8 - import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 9 9 10 10 @RoutePage() 11 11 class NewChatSearchPage extends ConsumerStatefulWidget { ··· 50 50 crossAxisAlignment: CrossAxisAlignment.start, 51 51 children: [ 52 52 Padding( 53 - padding: const EdgeInsets.all(16.0), 53 + padding: const EdgeInsets.all(16), 54 54 child: TextField( 55 55 controller: _searchController, 56 56 decoration: InputDecoration( ··· 77 77 borderRadius: BorderRadius.circular(8), 78 78 borderSide: BorderSide(color: colorScheme.outline), 79 79 ), 80 - contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), 80 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 81 81 ), 82 82 ), 83 83 ), ··· 111 111 unselectedLabelColor: theme.textTheme.bodyMedium?.color, 112 112 ), 113 113 ), 114 - Expanded(child: TabBarView(children: [const _UserResults()])), 114 + const Expanded(child: TabBarView(children: [_UserResults()])), 115 115 ], 116 116 ], 117 117 ), ··· 195 195 if (index >= state.searchResults.length) { 196 196 // Loading indicator at bottom 197 197 return const Padding( 198 - padding: EdgeInsets.all(16.0), 198 + padding: EdgeInsets.all(16), 199 199 child: Center(child: CircularProgressIndicator()), 200 200 ); 201 201 }
+3 -3
lib/src/features/messages/ui/widgets/conversation_list.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + 2 3 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 3 4 import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 4 - import 'conversation_list_item.dart'; 5 + import 'package:sparksocial/src/features/messages/ui/widgets/conversation_list_item.dart'; 5 6 6 7 class ConversationList extends StatelessWidget { 8 + const ConversationList({required this.conversations, super.key, this.onConversationTap, this.onConversationLongPress}); 7 9 final List<(ProfileViewDetailed, Message)> conversations; 8 10 final Function((ProfileViewDetailed, Message))? onConversationTap; 9 11 final Function((ProfileViewDetailed, Message))? onConversationLongPress; 10 - 11 - const ConversationList({super.key, required this.conversations, this.onConversationTap, this.onConversationLongPress}); 12 12 13 13 @override 14 14 Widget build(BuildContext context) {
+8 -9
lib/src/features/messages/ui/widgets/conversation_list_item.dart
··· 5 5 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 6 6 7 7 class ConversationListItem extends StatelessWidget { 8 - final Message message; 9 - final ProfileViewDetailed otherUserProfile; 10 - final VoidCallback? onTap; 11 - final VoidCallback? onLongPress; 12 - 13 8 const ConversationListItem({ 14 - super.key, 15 9 required this.message, 16 10 required this.otherUserProfile, 11 + super.key, 17 12 this.onTap, 18 13 this.onLongPress, 19 14 }); 15 + final Message message; 16 + final ProfileViewDetailed otherUserProfile; 17 + final VoidCallback? onTap; 18 + final VoidCallback? onLongPress; 20 19 21 20 @override 22 21 Widget build(BuildContext context) { ··· 24 23 onTap: onTap, 25 24 onLongPress: onLongPress, 26 25 child: Container( 27 - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), 26 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 28 27 decoration: BoxDecoration( 29 28 color: Theme.of(context).colorScheme.surface, 30 29 border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outline, width: 0.5)), ··· 113 112 } 114 113 115 114 class ConversationAvatar extends StatelessWidget { 115 + const ConversationAvatar({required this.otherUserProfile, super.key}); 116 116 final ProfileViewDetailed otherUserProfile; 117 - const ConversationAvatar({super.key, required this.otherUserProfile}); 118 117 119 118 Color _getAvatarColor(int seed) { 120 119 final colors = [ ··· 141 140 imageUrl: otherUserProfile.avatar.toString(), 142 141 username: otherUserProfile.handle, 143 142 size: 48, 144 - backgroundColor: _getAvatarColor((otherUserProfile.handle).hashCode), 143 + backgroundColor: _getAvatarColor(otherUserProfile.handle.hashCode), 145 144 ), 146 145 ); 147 146 }
+6 -8
lib/src/features/messages/ui/widgets/message_bubble.dart
··· 5 5 6 6 class MessageBubble extends StatelessWidget { 7 7 const MessageBubble({ 8 - super.key, 9 8 required this.message, 10 9 required this.isCurrentUser, 11 10 required this.showAvatar, 12 11 required this.otherUserAvatar, 13 12 required this.otherUserHandle, 13 + super.key, 14 14 }); 15 15 16 16 final Message message; ··· 24 24 r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)', 25 25 caseSensitive: false, 26 26 ); 27 - 28 - String cleanedText = text.replaceAll(urlPattern, '').trim(); 29 - 27 + 28 + var cleanedText = text.replaceAll(urlPattern, '').trim(); 29 + 30 30 // Clean up multiple spaces and newlines that might be left after URL removal 31 - cleanedText = cleanedText.replaceAll(RegExp(r'\s+'), ' ').trim(); 32 - 33 - return cleanedText; 31 + return cleanedText = cleanedText.replaceAll(RegExp(r'\s+'), ' ').trim(); 34 32 } 35 33 36 34 @override ··· 57 55 if (cleanedMessage.isEmpty) { 58 56 return const SizedBox.shrink(); 59 57 } 60 - 58 + 61 59 return Container( 62 60 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 63 61 decoration: BoxDecoration(
+8 -4
lib/src/features/messages/ui/widgets/message_input.dart
··· 1 - 2 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 2 import 'package:flutter/material.dart'; 4 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; ··· 9 8 import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 10 9 11 10 class MessageInput extends ConsumerWidget { 12 - const MessageInput({super.key, required this.controller, required this.onSend, this.isLoading = false, required this.otherDid, required this.imagePicker}); 11 + const MessageInput({ 12 + required this.controller, 13 + required this.onSend, 14 + required this.otherDid, 15 + required this.imagePicker, 16 + super.key, 17 + this.isLoading = false, 18 + }); 13 19 14 20 final TextEditingController controller; 15 21 final ImagePicker imagePicker; ··· 30 36 child: Column( 31 37 children: [ 32 38 Row( 33 - crossAxisAlignment: CrossAxisAlignment.center, 34 39 children: [ 35 40 UserAvatar( 36 41 imageUrl: ref ··· 42 47 ), 43 48 username: session?.handle ?? '', 44 49 size: 28, 45 - borderWidth: 0, 46 50 ), 47 51 const SizedBox(width: 8), 48 52 // _AttachmentButton(
+26 -26
lib/src/features/messages/ui/widgets/messages_list.dart
··· 14 14 15 15 class MessagesList extends StatelessWidget { 16 16 const MessagesList({ 17 - super.key, 18 17 required this.messages, 19 18 required this.scrollController, 20 19 required this.currentUserDid, 21 20 required this.otherUserHandle, 22 21 required this.otherUserAvatar, 22 + super.key, 23 23 }); 24 24 25 25 final List<Message> messages; ··· 30 30 31 31 Future<void> logLinkMetadata(List<String> links) async { 32 32 if (links.isEmpty) return; 33 - for (var link in links) { 33 + for (final link in links) { 34 34 try { 35 35 final metadata = await AnyLinkPreview.getMetadata(link: link); 36 36 GetIt.I<LogService>().getLogger('MessagesList').i('Link metadata for $link: $metadata'); ··· 48 48 return false; 49 49 } 50 50 if (res.statusCode != 200) return false; 51 - Map<String, dynamic> data = res.headers; 52 - return checkIfImage(data['content-type']); 51 + final Map<String, dynamic> data = res.headers; 52 + return checkIfImage(data['content-type'] as String); 53 53 } 54 54 55 55 bool checkIfImage(String param) { ··· 72 72 return false; 73 73 } 74 74 if (res.statusCode != 200) return false; 75 - Map<String, dynamic> data = res.headers; 76 - return checkIfVideo(data['content-type']); 75 + final Map<String, dynamic> data = res.headers; 76 + return checkIfVideo(data['content-type'] as String); 77 77 } 78 78 79 79 bool checkIfVideo(String param) { ··· 105 105 List<Widget>? embeds; 106 106 107 107 if (embed?.isNotEmpty ?? false) { 108 - List<String> images = []; 109 - List<String> videos = []; 110 - List<String> links = []; 111 - List<String> sprkPosts = []; 108 + final images = <String>[]; 109 + final videos = <String>[]; 110 + final links = <String>[]; 111 + final sprkPosts = <String>[]; 112 112 for (final embed in embed!) { 113 113 if (embed.type == 'image') { 114 114 if (embed.url?.isNotEmpty ?? false) { ··· 132 132 } 133 133 134 134 // Check links for images/videos/sprk posts and reclassify them 135 - List<String> linksToRemove = []; 136 - for (var link in links) { 135 + final linksToRemove = <String>[]; 136 + for (final link in links) { 137 137 if (link.isEmpty) continue; 138 138 if (Uri.tryParse(link)?.hasScheme != true) continue; // Skip invalid links 139 139 ··· 154 154 } 155 155 156 156 // Remove reclassified links 157 - for (var linkToRemove in linksToRemove) { 157 + for (final linkToRemove in linksToRemove) { 158 158 links.remove(linkToRemove); 159 159 } 160 160 ··· 164 164 } 165 165 if (videos.isNotEmpty) { 166 166 embeds ??= []; 167 - for (var videoUrl in videos) { 167 + for (final videoUrl in videos) { 168 168 embeds.add(VideoContent(borderRadius: BorderRadius.circular(12), videoUrl: videoUrl)); 169 169 } 170 170 } 171 171 if (sprkPosts.isNotEmpty) { 172 172 embeds ??= []; 173 - for (var postUri in sprkPosts) { 173 + for (final postUri in sprkPosts) { 174 174 embeds.add(_SprkPostThumbnail(postUri: postUri)); 175 175 } 176 176 } ··· 186 186 itemCount: links.length, 187 187 itemBuilder: (context, index) { 188 188 return Padding( 189 - padding: const EdgeInsets.only(top: 8.0), 189 + padding: const EdgeInsets.only(top: 8), 190 190 child: _LinkPreview(url: links[index]), 191 191 ); 192 192 }, ··· 316 316 if (await canLaunchUrl(uri)) { 317 317 await launchUrl(uri, mode: LaunchMode.externalApplication); 318 318 } else { 319 - await launchUrl(uri, mode: LaunchMode.platformDefault); 319 + await launchUrl(uri); 320 320 } 321 321 } catch (e) { 322 322 GetIt.I<LogService>().getLogger('_LinkPreview').e('Failed to launch URL $url: $e'); ··· 385 385 ), 386 386 Expanded( 387 387 child: Padding( 388 - padding: const EdgeInsets.all(8.0), 388 + padding: const EdgeInsets.all(8), 389 389 child: FittedBox(child: Text(urlStr, style: theme.textTheme.titleSmall)), 390 390 ), 391 391 ), ··· 404 404 Widget build(BuildContext context) { 405 405 final theme = Theme.of(context); 406 406 407 - final title = metadata.title?.isNotEmpty == true && metadata.title != 'null' ? metadata.title : null; 408 - final desc = metadata.desc?.isNotEmpty == true && metadata.desc != 'null' ? metadata.desc : null; 407 + final title = metadata.title?.isNotEmpty ?? true && metadata.title != 'null' ? metadata.title : null; 408 + final desc = metadata.desc?.isNotEmpty ?? true && metadata.desc != 'null' ? metadata.desc : null; 409 409 410 410 return Padding( 411 - padding: const EdgeInsets.all(8.0), 411 + padding: const EdgeInsets.all(8), 412 412 child: Column( 413 413 crossAxisAlignment: CrossAxisAlignment.start, 414 414 children: [ ··· 503 503 ), 504 504 Expanded( 505 505 child: Padding( 506 - padding: const EdgeInsets.all(12.0), 506 + padding: const EdgeInsets.all(12), 507 507 child: Column( 508 508 crossAxisAlignment: CrossAxisAlignment.start, 509 509 mainAxisAlignment: MainAxisAlignment.center, ··· 538 538 void _navigateToPost(BuildContext context) { 539 539 try { 540 540 // Transform the URI format: insert /so.sprk.feed.post before the post ID 541 - String transformedUri = postUri; 541 + var transformedUri = postUri; 542 542 543 543 // Find the last slash and insert /so.sprk.feed.post before the post ID 544 - int lastSlashIndex = postUri.lastIndexOf('/'); 544 + final lastSlashIndex = postUri.lastIndexOf('/'); 545 545 if (lastSlashIndex != -1) { 546 - String beforePostId = postUri.substring(0, lastSlashIndex); 547 - String postId = postUri.substring(lastSlashIndex + 1); 546 + final beforePostId = postUri.substring(0, lastSlashIndex); 547 + final postId = postUri.substring(lastSlashIndex + 1); 548 548 transformedUri = '$beforePostId/so.sprk.feed.post/$postId'; 549 549 } 550 550
+2 -3
lib/src/features/messages/ui/widgets/sender_avatar.dart
··· 4 4 import 'package:sparksocial/src/features/messages/ui/pages/chat_page.dart'; 5 5 6 6 class SenderAvatar extends StatelessWidget { 7 - const SenderAvatar({super.key, required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle}); 7 + const SenderAvatar({required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle, super.key}); 8 8 9 9 final bool isCurrentUser; 10 10 final String? otherUserAvatar; ··· 13 13 @override 14 14 Widget build(BuildContext context) { 15 15 if (isCurrentUser) { 16 - return UserAvatar( 17 - imageUrl: null, // Current user avatar - can be added later 16 + return const UserAvatar( 18 17 username: 'You', 19 18 size: 32, 20 19 backgroundColor: AppColors.primary,
+2 -4
lib/src/features/posting/providers/camera_provider.dart
··· 16 16 FutureOr<CameraState> build() async { 17 17 _logger = GetIt.instance<LogService>().getLogger('Camera'); 18 18 19 - ref.onDispose(() { 20 - _disposeCamera(); 21 - }); 19 + ref.onDispose(_disposeCamera); 22 20 23 21 _logger.i('Initializing camera provider'); 24 22 ··· 55 53 Future<CameraController> _createCameraController(CameraDescription camera) async { 56 54 _logger.d('Creating camera controller for: ${camera.name}'); 57 55 58 - final controller = CameraController(camera, ResolutionPreset.max, enableAudio: true, imageFormatGroup: ImageFormatGroup.jpeg); 56 + final controller = CameraController(camera, ResolutionPreset.max, imageFormatGroup: ImageFormatGroup.jpeg); 59 57 60 58 await controller.initialize(); 61 59
+10 -12
lib/src/features/posting/providers/upload_provider.dart
··· 2 2 3 3 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 4 5 - import 'upload_state.dart'; 5 + import 'package:sparksocial/src/features/posting/providers/upload_state.dart'; 6 6 7 7 part 'upload_provider.g.dart'; 8 8 ··· 21 21 22 22 // Register a new upload task 23 23 String registerTask(String type) { 24 - final String id = DateTime.now().millisecondsSinceEpoch.toString(); 25 - final UploadTask newTask = UploadTask(id: id, type: type); 24 + final id = DateTime.now().millisecondsSinceEpoch.toString(); 25 + final newTask = UploadTask(id: id, type: type); 26 26 27 27 state = state.copyWith(tasks: {...state.tasks, id: newTask}); 28 28 ··· 32 32 // Start an upload task 33 33 void startTask(String id) { 34 34 if (state.tasks.containsKey(id)) { 35 - final UploadTask updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.uploading); 35 + final updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.uploading); 36 36 37 37 state = state.copyWith(tasks: {...state.tasks, id: updatedTask}); 38 38 ··· 43 43 // Complete an upload task 44 44 void completeTask(String id) { 45 45 if (state.tasks.containsKey(id)) { 46 - final UploadTask updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.completed); 46 + final updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.completed); 47 47 48 48 state = state.copyWith(tasks: {...state.tasks, id: updatedTask}); 49 49 ··· 56 56 // Mark a task as failed 57 57 void failTask(String id, String errorMessage) { 58 58 if (state.tasks.containsKey(id)) { 59 - final UploadTask updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.error, errorMessage: errorMessage); 59 + final updatedTask = state.tasks[id]!.copyWith(status: UploadStatus.error, errorMessage: errorMessage); 60 60 61 61 state = state.copyWith(tasks: {...state.tasks, id: updatedTask}); 62 62 ··· 66 66 67 67 // Clear all completed tasks 68 68 void clearCompletedTasks() { 69 - final Map<String, UploadTask> filteredTasks = Map.fromEntries( 69 + final filteredTasks = Map<String, UploadTask>.fromEntries( 70 70 state.tasks.entries.where((entry) => entry.value.status != UploadStatus.completed), 71 71 ); 72 72 ··· 75 75 } 76 76 77 77 void _updateActiveStatus() { 78 - final bool isAnyTaskActive = state.tasks.values.any((task) => task.status == UploadStatus.uploading); 78 + final isAnyTaskActive = state.tasks.values.any((task) => task.status == UploadStatus.uploading); 79 79 80 80 state = state.copyWith(isAnyTaskActive: isAnyTaskActive); 81 81 } 82 82 83 83 void _updateCompletedStatus() { 84 - final bool isAnyTaskCompleted = state.tasks.values.any((task) => task.status == UploadStatus.completed); 84 + final isAnyTaskCompleted = state.tasks.values.any((task) => task.status == UploadStatus.completed); 85 85 86 86 state = state.copyWith(isAnyTaskCompleted: isAnyTaskCompleted); 87 87 } ··· 91 91 _completedTasksTimer?.cancel(); 92 92 93 93 // Set up new timer to clear completed tasks after 3 seconds 94 - _completedTasksTimer = Timer(const Duration(seconds: 3), () { 95 - clearCompletedTasks(); 96 - }); 94 + _completedTasksTimer = Timer(const Duration(seconds: 3), clearCompletedTasks); 97 95 } 98 96 }
+7 -9
lib/src/features/posting/providers/video_upload_provider.dart
··· 1 - 2 1 import 'package:atproto/core.dart'; 3 2 import 'package:get_it/get_it.dart'; 4 3 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 5 5 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 6 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 6 7 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 7 - 8 - import '../../../core/auth/data/repositories/auth_repository.dart'; 9 - import '../../../core/utils/logging/log_service.dart'; 10 - import 'video_upload_state.dart'; 8 + import 'package:sparksocial/src/features/posting/providers/video_upload_state.dart'; 11 9 12 10 part 'video_upload_provider.g.dart'; 13 11 ··· 134 132 135 133 // Create Bluesky video post record using direct JSON structure 136 134 final bskyPostRecord = <String, dynamic>{ 137 - "\$type": "app.bsky.feed.post", 138 - "text": text, 139 - "embed": {"\$type": "app.bsky.embed.video", "video": blob.toJson(), "alt": altText}, 140 - "createdAt": DateTime.now().toUtc().toIso8601String(), 135 + r'$type': 'app.bsky.feed.post', 136 + 'text': text, 137 + 'embed': {r'$type': 'app.bsky.embed.video', 'video': blob.toJson(), 'alt': altText}, 138 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 141 139 }; 142 140 143 141 final bskyAtProto = _authRepository.atproto!;
+21 -22
lib/src/features/posting/ui/pages/create_video_page.dart
··· 16 16 17 17 @RoutePage() 18 18 class CreateVideoPage extends ConsumerStatefulWidget { 19 + const CreateVideoPage({super.key, this.isStoryMode = false}); 19 20 final bool isStoryMode; 20 - 21 - const CreateVideoPage({super.key, this.isStoryMode = false}); 22 21 23 22 @override 24 23 ConsumerState<CreateVideoPage> createState() => _CreateVideoPageState(); ··· 27 26 class _CreateVideoPageState extends ConsumerState<CreateVideoPage> with WidgetsBindingObserver { 28 27 bool _isVideoMode = true; 29 28 bool _isRecording = false; 30 - double _recordingProgress = 0.0; 29 + double _recordingProgress = 0; 31 30 String _recordingTimeText = '00:00 / 03:00'; 32 31 Timer? _recordingTimer; 33 32 int _recordingSeconds = 0; ··· 55 54 super.dispose(); 56 55 } 57 56 58 - void _onVideoGalleryPressed() async { 57 + Future<void> _onVideoGalleryPressed() async { 59 58 try { 60 - final XFile? video = await _picker.pickVideo(source: ImageSource.gallery, maxDuration: const Duration(seconds: 180)); 59 + final video = await _picker.pickVideo(source: ImageSource.gallery, maxDuration: const Duration(seconds: 180)); 61 60 62 61 if (video != null && mounted) { 63 62 if (widget.isStoryMode) { ··· 73 72 builder: (BuildContext context) { 74 73 return AlertDialog( 75 74 title: const Text('Error'), 76 - content: Text('Failed to select video: ${e.toString()}'), 75 + content: Text('Failed to select video: $e'), 77 76 actions: [TextButton(onPressed: () => context.router.maybePop(), child: const Text('OK'))], 78 77 ); 79 78 }, ··· 82 81 } 83 82 } 84 83 85 - void _onImageGalleryPressed() async { 84 + Future<void> _onImageGalleryPressed() async { 86 85 try { 87 86 if (widget.isStoryMode) { 88 87 // For stories, only allow one image 89 - final XFile? image = await _picker.pickImage(source: ImageSource.gallery); 88 + final image = await _picker.pickImage(source: ImageSource.gallery); 90 89 if (image != null && mounted) { 91 90 context.router.push(StoryReviewRoute(videoPath: '', imageFile: image)); 92 91 } 93 92 } else { 94 93 // For regular posts, allow multiple images 95 94 const maxImages = 12; 96 - final List<XFile> pickedFiles = await _picker.pickMultiImage(limit: maxImages); 95 + final pickedFiles = await _picker.pickMultiImage(limit: maxImages); 97 96 if (pickedFiles.isEmpty) return; 98 - final List<XFile> limitedFiles = pickedFiles.length > maxImages ? pickedFiles.sublist(0, maxImages) : pickedFiles; 97 + final limitedFiles = pickedFiles.length > maxImages ? pickedFiles.sublist(0, maxImages) : pickedFiles; 99 98 if (!mounted) return; 100 99 context.router.push(ImageReviewRoute(imageFiles: limitedFiles)); 101 100 } ··· 106 105 builder: (BuildContext context) { 107 106 return AlertDialog( 108 107 title: const Text('Error'), 109 - content: Text('Failed to select images: ${e.toString()}'), 108 + content: Text('Failed to select images: $e'), 110 109 actions: [TextButton(onPressed: () => context.router.maybePop(), child: const Text('OK'))], 111 110 ); 112 111 }, ··· 114 113 } 115 114 } 116 115 117 - void _onCapturePressed() async { 116 + Future<void> _onCapturePressed() async { 118 117 if (!_isVideoMode) { 119 118 await _takePhoto(); 120 119 } else { ··· 123 122 } 124 123 125 124 Future<void> _takePhoto() async { 126 - final XFile? photo = await ref.read(cameraProvider.notifier).takePhoto(); 125 + final photo = await ref.read(cameraProvider.notifier).takePhoto(); 127 126 if (photo != null) { 128 127 if (widget.isStoryMode) { 129 128 if (mounted) { ··· 135 134 136 135 Future<void> _toggleVideoRecording() async { 137 136 if (_isRecording) { 138 - final XFile? video = await ref.read(cameraProvider.notifier).stopVideoRecording(); 137 + final video = await ref.read(cameraProvider.notifier).stopVideoRecording(); 139 138 _stopRecordingTimer(); 140 139 141 140 setState(() { ··· 155 154 } 156 155 } 157 156 } else { 158 - bool success = await ref.read(cameraProvider.notifier).startVideoRecording(); 157 + final success = await ref.read(cameraProvider.notifier).startVideoRecording(); 159 158 if (success) { 160 159 setState(() { 161 160 _isRecording = true; ··· 176 175 _recordingSeconds++; 177 176 _recordingProgress = _recordingSeconds / _maxRecordingSeconds; 178 177 179 - final int minutes = _recordingSeconds ~/ 60; 180 - final int seconds = _recordingSeconds % 60; 181 - final String minutesStr = minutes.toString().padLeft(2, '0'); 182 - final String secondsStr = seconds.toString().padLeft(2, '0'); 178 + final minutes = _recordingSeconds ~/ 60; 179 + final seconds = _recordingSeconds % 60; 180 + final minutesStr = minutes.toString().padLeft(2, '0'); 181 + final secondsStr = seconds.toString().padLeft(2, '0'); 183 182 184 183 _recordingTimeText = '$minutesStr:$secondsStr / 03:00'; 185 184 }); ··· 219 218 ), 220 219 ), 221 220 ), 222 - 221 + 223 222 Positioned( 224 223 top: 20, 225 224 left: 0, ··· 231 230 ), 232 231 ), 233 232 ), 234 - 233 + 235 234 Positioned( 236 235 bottom: 30, 237 236 left: 0, ··· 242 241 RecordingBar(isRecording: _isRecording, progress: _recordingProgress, timeText: _recordingTimeText), 243 242 const SizedBox(height: 20), 244 243 ], 245 - 244 + 246 245 CameraControls( 247 246 isVideoMode: _isVideoMode, 248 247 isRecording: _isRecording,
+19 -21
lib/src/features/posting/ui/pages/image_review_page.dart
··· 29 29 30 30 @RoutePage() 31 31 class ImageReviewPage extends ConsumerStatefulWidget { 32 + const ImageReviewPage({required this.imageFiles, super.key}); 32 33 final List<XFile> imageFiles; 33 - 34 - const ImageReviewPage({super.key, required this.imageFiles}); 35 34 36 35 @override 37 36 ConsumerState<ImageReviewPage> createState() => _ImageReviewPageState(); ··· 70 69 super.dispose(); 71 70 } 72 71 73 - void _editAltText(XFile imageFile) async { 72 + Future<void> _editAltText(XFile imageFile) async { 74 73 final path = imageFile.path; 75 74 final initialText = _altTexts[path] ?? ''; 76 75 final result = await showDialog<String>( ··· 84 83 } 85 84 86 85 Future<void> _pickMoreImages() async { 87 - final int remaining = _maxImages - _imageFiles.length; 86 + final remaining = _maxImages - _imageFiles.length; 88 87 if (remaining <= 0) return; 89 88 try { 90 - final List<XFile> pickedFiles = await _picker.pickMultiImage(limit: remaining); 89 + final pickedFiles = await _picker.pickMultiImage(limit: remaining); 91 90 if (pickedFiles.isEmpty) return; 92 91 setState(() { 93 92 _imageFiles.addAll(pickedFiles); ··· 99 98 if (!mounted) return; 100 99 ScaffoldMessenger.of( 101 100 context, 102 - ).showSnackBar(SnackBar(content: Text('Failed to select images: ${e.toString()}'), backgroundColor: Colors.red)); 101 + ).showSnackBar(SnackBar(content: Text('Failed to select images: $e'), backgroundColor: Colors.red)); 103 102 } 104 103 } 105 104 ··· 127 126 }); 128 127 ScaffoldMessenger.of( 129 128 context, 130 - ).showSnackBar(SnackBar(content: Text('Failed to create post: ${e.toString()}'), backgroundColor: Colors.red)); 129 + ).showSnackBar(SnackBar(content: Text('Failed to create post: $e'), backgroundColor: Colors.red)); 131 130 final uploadService = ref.read(uploadProvider.notifier); 132 131 final tasks = uploadService.registerTask('image'); 133 132 uploadService.failTask(tasks, e.toString()); ··· 156 155 Expanded( 157 156 child: SingleChildScrollView( 158 157 child: Padding( 159 - padding: const EdgeInsets.all(16.0), 158 + padding: const EdgeInsets.all(16), 160 159 child: Column( 161 160 crossAxisAlignment: CrossAxisAlignment.start, 162 161 children: [ 163 162 if (_imageFiles.isNotEmpty) 164 163 AspectRatio( 165 - aspectRatio: 1.0, 164 + aspectRatio: 1, 166 165 child: Stack( 167 166 alignment: Alignment.bottomCenter, 168 167 children: [ ··· 176 175 child: Stack( 177 176 children: [ 178 177 Container( 179 - margin: const EdgeInsets.symmetric(horizontal: 4.0), 178 + margin: const EdgeInsets.symmetric(horizontal: 4), 180 179 decoration: BoxDecoration( 181 180 borderRadius: BorderRadius.circular(8), 182 181 image: DecorationImage(image: FileImage(File(image.path)), fit: BoxFit.cover), ··· 194 193 child: InkWell( 195 194 onTap: () => _editAltText(image), 196 195 borderRadius: BorderRadius.circular(8), 197 - child: Padding( 198 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 196 + child: const Padding( 197 + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), 199 198 child: Row( 200 199 children: [ 201 200 Icon( ··· 203 202 color: Colors.white, 204 203 size: 16, 205 204 ), 206 - const SizedBox(width: 2), 205 + SizedBox(width: 2), 207 206 Text( 208 - "ALT", 207 + 'ALT', 209 208 style: TextStyle( 210 209 color: Colors.white, 211 210 fontSize: 12, ··· 294 293 crossAxisAlignment: CrossAxisAlignment.start, 295 294 children: [ 296 295 Material( 297 - elevation: 0, 298 296 color: Colors.transparent, 299 297 borderRadius: BorderRadius.circular(12), 300 298 child: TextField( ··· 307 305 hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), 308 306 border: OutlineInputBorder( 309 307 borderRadius: BorderRadius.circular(12), 310 - borderSide: BorderSide(color: theme.colorScheme.outline, width: 1), 308 + borderSide: BorderSide(color: theme.colorScheme.outline), 311 309 ), 312 310 enabledBorder: OutlineInputBorder( 313 311 borderRadius: BorderRadius.circular(12), 314 - borderSide: BorderSide(color: theme.colorScheme.outline, width: 1), 312 + borderSide: BorderSide(color: theme.colorScheme.outline), 315 313 ), 316 314 focusedBorder: OutlineInputBorder( 317 315 borderRadius: BorderRadius.circular(12), ··· 376 374 color: Colors.orange.withAlpha(25), 377 375 borderRadius: BorderRadius.circular(8), 378 376 ), 379 - child: Row( 377 + child: const Row( 380 378 children: [ 381 - const Icon(Icons.info_outline, color: Colors.orange, size: 20), 382 - const SizedBox(width: 8), 379 + Icon(Icons.info_outline, color: Colors.orange, size: 20), 380 + SizedBox(width: 8), 383 381 Expanded( 384 382 child: Text( 385 383 'Bluesky supports a maximum of 4 images. Your Bluesky post will link to the full Spark post instead.', ··· 400 398 ), 401 399 ), 402 400 Padding( 403 - padding: const EdgeInsets.all(16.0), 401 + padding: const EdgeInsets.all(16), 404 402 child: SizedBox( 405 403 width: double.infinity, 406 404 child: ElevatedButton(
+15 -17
lib/src/features/posting/ui/pages/story_review_page.dart
··· 3 3 import 'package:auto_route/auto_route.dart'; 4 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 5 import 'package:flutter/material.dart'; 6 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 7 import 'package:get_it/get_it.dart'; 7 8 import 'package:image_picker/image_picker.dart'; 8 9 import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Image; ··· 14 15 import 'package:sparksocial/src/features/posting/providers/video_upload_state.dart'; 15 16 import 'package:sparksocial/src/features/posting/ui/widgets/video_thumbnail.dart'; 16 17 import 'package:video_player/video_player.dart'; 17 - import 'package:flutter_riverpod/flutter_riverpod.dart'; 18 18 19 19 @RoutePage() 20 20 class StoryReviewPage extends ConsumerStatefulWidget { 21 + const StoryReviewPage({required this.videoPath, required this.imageFile, super.key}); 21 22 final String videoPath; 22 23 final XFile imageFile; 23 - 24 - const StoryReviewPage({super.key, required this.videoPath, required this.imageFile}); 25 24 26 25 @override 27 26 ConsumerState<StoryReviewPage> createState() => _StoryReviewPageState(); ··· 43 42 void _initVideoPlayer() { 44 43 if (widget.videoPath.isEmpty) return; 45 44 46 - String videoPath = widget.videoPath; 45 + var videoPath = widget.videoPath; 47 46 if (videoPath.startsWith('file://')) { 48 47 videoPath = videoPath.replaceFirst('file://', ''); 49 48 } ··· 94 93 95 94 ScaffoldMessenger.of( 96 95 context, 97 - ).showSnackBar(SnackBar(content: Text('Failed to post story: ${e.toString()}'), backgroundColor: Colors.red)); 96 + ).showSnackBar(SnackBar(content: Text('Failed to post story: $e'), backgroundColor: Colors.red)); 98 97 99 98 final uploadService = ref.read(uploadProvider.notifier); 100 99 final taskId = uploadService.registerTask('story'); ··· 157 156 Expanded( 158 157 child: SingleChildScrollView( 159 158 child: Padding( 160 - padding: const EdgeInsets.all(16.0), 159 + padding: const EdgeInsets.all(16), 161 160 child: Column( 162 - crossAxisAlignment: CrossAxisAlignment.center, 163 161 children: [ 164 162 // Media preview 165 163 LayoutBuilder( ··· 203 201 } 204 202 }, 205 203 borderRadius: BorderRadius.circular(8), 206 - child: Padding( 207 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 204 + child: const Padding( 205 + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), 208 206 child: Row( 209 207 mainAxisSize: MainAxisSize.min, 210 208 children: [ 211 209 Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 212 - const SizedBox(width: 4), 213 - const Text( 210 + SizedBox(width: 4), 211 + Text( 214 212 'ALT', 215 213 style: TextStyle( 216 214 color: Colors.white, ··· 230 228 } 231 229 232 230 // Fallback loader if neither video nor image is ready 233 - return SizedBox( 231 + return const SizedBox( 234 232 height: maxHeight, 235 233 width: double.infinity, 236 - child: const Center(child: CircularProgressIndicator()), 234 + child: Center(child: CircularProgressIndicator()), 237 235 ); 238 236 } 239 237 ··· 265 263 } 266 264 267 265 final aspectRatio = _controller!.value.aspectRatio; 268 - double width = maxWidth; 269 - double height = width / aspectRatio; 266 + var width = maxWidth; 267 + var height = width / aspectRatio; 270 268 if (height > maxHeight) { 271 269 height = maxHeight; 272 270 width = height * aspectRatio; ··· 317 315 child: Row( 318 316 mainAxisSize: MainAxisSize.min, 319 317 children: [ 320 - Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 318 + const Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 321 319 const SizedBox(width: 4), 322 320 Text( 323 321 _altText.isEmpty ? 'ALT' : 'ALT', ··· 344 342 ), 345 343 ), 346 344 Padding( 347 - padding: const EdgeInsets.all(16.0), 345 + padding: const EdgeInsets.all(16), 348 346 child: SizedBox( 349 347 width: double.infinity, 350 348 child: ElevatedButton(
+2 -4
lib/src/features/posting/ui/pages/video_playback_page.dart
··· 6 6 7 7 @RoutePage() 8 8 class VideoPlaybackPage extends StatefulWidget { 9 + const VideoPlaybackPage({required this.controller, super.key}); 9 10 final VideoPlayerController controller; 10 - 11 - const VideoPlaybackPage({super.key, required this.controller}); 12 11 13 12 @override 14 13 State<VideoPlaybackPage> createState() => _VideoPlaybackPageState(); ··· 97 96 // Controls overlay 98 97 if (_showControls) 99 98 Positioned.fill( 100 - child: Container( 99 + child: ColoredBox( 101 100 color: Colors.black.withAlpha(100), 102 101 child: Stack( 103 102 alignment: Alignment.center, ··· 154 153 ), 155 154 child: Slider( 156 155 value: position.inMilliseconds.toDouble(), 157 - min: 0, 158 156 max: duration.inMilliseconds.toDouble(), 159 157 onChanged: (value) { 160 158 widget.controller.seekTo(Duration(milliseconds: value.toInt()));
+13 -17
lib/src/features/posting/ui/pages/video_review_page.dart
··· 11 11 import 'package:sparksocial/src/features/posting/providers/video_upload_provider.dart'; 12 12 import 'package:sparksocial/src/features/posting/providers/video_upload_state.dart'; 13 13 import 'package:sparksocial/src/features/posting/ui/widgets/video_thumbnail.dart'; 14 - import 'package:video_player/video_player.dart'; 15 14 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 15 + import 'package:video_player/video_player.dart'; 16 16 17 17 @RoutePage() 18 18 class VideoReviewPage extends ConsumerStatefulWidget { 19 + const VideoReviewPage({required this.videoPath, super.key}); 19 20 final String videoPath; 20 - 21 - const VideoReviewPage({super.key, required this.videoPath}); 22 21 23 22 @override 24 23 ConsumerState<VideoReviewPage> createState() => _VideoReviewPageState(); ··· 37 36 } 38 37 39 38 void _initVideoPlayer() { 40 - String videoPath = widget.videoPath; 39 + var videoPath = widget.videoPath; 41 40 42 41 // Handle file:// URL scheme 43 42 if (videoPath.startsWith('file://')) { ··· 105 104 // Show error without blocking UI 106 105 ScaffoldMessenger.of( 107 106 context, 108 - ).showSnackBar(SnackBar(content: Text('Failed to upload video: ${e.toString()}'), backgroundColor: Colors.red)); 107 + ).showSnackBar(SnackBar(content: Text('Failed to upload video: $e'), backgroundColor: Colors.red)); 109 108 110 109 // Update upload service with error state 111 110 final uploadNotifier = ref.read(uploadProvider.notifier); ··· 135 134 Expanded( 136 135 child: SingleChildScrollView( 137 136 child: Padding( 138 - padding: const EdgeInsets.all(16.0), 137 + padding: const EdgeInsets.all(16), 139 138 child: Column( 140 - crossAxisAlignment: CrossAxisAlignment.center, 141 139 children: [ 142 140 // Video preview big on top with ALT overlay 143 141 LayoutBuilder( 144 142 builder: (context, constraints) { 145 143 final maxWidth = constraints.maxWidth; 146 - final maxHeight = 320.0; 144 + const maxHeight = 320.0; 147 145 if (!_controller.value.isInitialized) { 148 146 return SizedBox( 149 147 height: maxHeight, ··· 170 168 ); 171 169 } 172 170 final aspectRatio = _controller.value.aspectRatio; 173 - double width = maxWidth; 174 - double height = width / aspectRatio; 171 + var width = maxWidth; 172 + var height = width / aspectRatio; 175 173 if (height > maxHeight) { 176 174 height = maxHeight; 177 175 width = height * aspectRatio; ··· 201 199 _controller.pause(); 202 200 final result = await showDialog<String>( 203 201 context: context, 204 - builder: (context) => 205 - AltTextEditorDialog(imageFile: null, initialAltText: _videoAltText), 202 + builder: (context) => AltTextEditorDialog(initialAltText: _videoAltText), 206 203 ); 207 204 if (result != null) { 208 205 setState(() { ··· 219 216 child: Row( 220 217 mainAxisSize: MainAxisSize.min, 221 218 children: [ 222 - Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 219 + const Icon(FluentIcons.image_alt_text_20_regular, color: Colors.white, size: 16), 223 220 const SizedBox(width: 4), 224 221 Text( 225 222 _videoAltText.isEmpty ? 'ALT' : 'ALT', ··· 251 248 crossAxisAlignment: CrossAxisAlignment.start, 252 249 children: [ 253 250 Material( 254 - elevation: 0, 255 251 color: Colors.transparent, 256 252 borderRadius: BorderRadius.circular(12), 257 253 child: TextField( ··· 264 260 hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), 265 261 border: OutlineInputBorder( 266 262 borderRadius: BorderRadius.circular(12), 267 - borderSide: BorderSide(color: theme.colorScheme.outline, width: 1), 263 + borderSide: BorderSide(color: theme.colorScheme.outline), 268 264 ), 269 265 enabledBorder: OutlineInputBorder( 270 266 borderRadius: BorderRadius.circular(12), 271 - borderSide: BorderSide(color: theme.colorScheme.outline, width: 1), 267 + borderSide: BorderSide(color: theme.colorScheme.outline), 272 268 ), 273 269 focusedBorder: OutlineInputBorder( 274 270 borderRadius: BorderRadius.circular(12), ··· 333 329 ), 334 330 ), 335 331 Padding( 336 - padding: const EdgeInsets.all(16.0), 332 + padding: const EdgeInsets.all(16), 337 333 child: SizedBox( 338 334 width: double.infinity, 339 335 child: ElevatedButton(
+13 -15
lib/src/features/posting/ui/widgets/camera_controls.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 4 4 class CameraControls extends StatelessWidget { 5 - final bool isVideoMode; 6 - final bool isRecording; 7 - final VoidCallback onCapturePressed; 8 - final VoidCallback onFlipCameraPressed; 9 - final VoidCallback onGalleryPressed; 10 - final VoidCallback onImageGalleryPressed; 11 - 12 5 const CameraControls({ 13 - super.key, 14 6 required this.isVideoMode, 15 7 required this.isRecording, 16 8 required this.onCapturePressed, 17 9 required this.onFlipCameraPressed, 18 10 required this.onGalleryPressed, 19 11 required this.onImageGalleryPressed, 12 + super.key, 20 13 }); 14 + final bool isVideoMode; 15 + final bool isRecording; 16 + final VoidCallback onCapturePressed; 17 + final VoidCallback onFlipCameraPressed; 18 + final VoidCallback onGalleryPressed; 19 + final VoidCallback onImageGalleryPressed; 21 20 22 21 @override 23 22 Widget build(BuildContext context) { 24 23 return Padding( 25 - padding: const EdgeInsets.symmetric(horizontal: 20.0), 24 + padding: const EdgeInsets.symmetric(horizontal: 20), 26 25 child: Row( 27 26 mainAxisAlignment: MainAxisAlignment.spaceBetween, 28 - crossAxisAlignment: CrossAxisAlignment.center, 29 27 children: [ 30 28 if (isVideoMode) 31 29 TooltipIconButton(icon: FluentIcons.image_multiple_24_regular, onPressed: onGalleryPressed, tooltip: 'Select Video') ··· 46 44 } 47 45 48 46 class TooltipIconButton extends StatelessWidget { 49 - const TooltipIconButton({super.key, required this.icon, required this.onPressed, required this.tooltip}); 47 + const TooltipIconButton({required this.icon, required this.onPressed, required this.tooltip, super.key}); 50 48 51 49 final IconData icon; 52 50 final VoidCallback onPressed; ··· 65 63 } 66 64 67 65 class CaptureButton extends StatelessWidget { 68 - const CaptureButton({super.key, required this.isRecording, required this.onCapturePressed, required this.isVideoMode}); 66 + const CaptureButton({required this.isRecording, required this.onCapturePressed, required this.isVideoMode, super.key}); 69 67 70 68 final bool isRecording; 71 69 final VoidCallback onCapturePressed; ··· 73 71 74 72 @override 75 73 Widget build(BuildContext context) { 76 - final double size = isRecording ? 50.0 : 70.0; 77 - final double innerPadding = isRecording ? 5.0 : 3.0; 78 - final innerShape = isRecording ? BorderRadius.circular(8.0) : null; 74 + final size = isRecording ? 50.0 : 70.0; 75 + final innerPadding = isRecording ? 5.0 : 3.0; 76 + final innerShape = isRecording ? BorderRadius.circular(8) : null; 79 77 80 78 return GestureDetector( 81 79 onTap: onCapturePressed,
+6 -7
lib/src/features/posting/ui/widgets/camera_view.dart
··· 1 1 import 'dart:math' as math; 2 2 3 - import 'package:flutter/material.dart'; 4 3 import 'package:camera/camera.dart'; 4 + import 'package:flutter/material.dart'; 5 5 6 6 class CameraView extends StatefulWidget { 7 + const CameraView({required this.cameraController, required this.isInitialized, super.key}); 7 8 final CameraController? cameraController; 8 9 final bool isInitialized; 9 - 10 - const CameraView({super.key, required this.cameraController, required this.isInitialized}); 11 10 12 11 @override 13 12 State<CameraView> createState() => _CameraViewState(); ··· 17 16 @override 18 17 Widget build(BuildContext context) { 19 18 if (!widget.isInitialized || widget.cameraController == null || !widget.cameraController!.value.isInitialized) { 20 - return Container( 19 + return const ColoredBox( 21 20 color: Colors.black, 22 - child: const Center( 21 + child: Center( 23 22 child: Column( 24 23 mainAxisSize: MainAxisSize.min, 25 24 children: [ ··· 48 47 child: CameraPreview(widget.cameraController!), 49 48 ); 50 49 } catch (e) { 51 - return Container( 50 + return const ColoredBox( 52 51 color: Colors.black, 53 - child: const Center( 52 + child: Center( 54 53 child: Text('Camera preview unavailable', style: TextStyle(color: Colors.red, fontSize: 16)), 55 54 ), 56 55 );
+2 -3
lib/src/features/posting/ui/widgets/mode_selector.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 class ModeSelector extends StatelessWidget { 4 + const ModeSelector({required this.isVideoMode, required this.onModeSelected, super.key}); 4 5 final bool isVideoMode; 5 6 final Function(bool) onModeSelected; 6 - 7 - const ModeSelector({super.key, required this.isVideoMode, required this.onModeSelected}); 8 7 9 8 @override 10 9 Widget build(BuildContext context) { ··· 23 22 } 24 23 25 24 class ModeButton extends StatelessWidget { 26 - const ModeButton({super.key, required this.label, required this.isSelected, required this.onTap}); 25 + const ModeButton({required this.label, required this.isSelected, required this.onTap, super.key}); 27 26 28 27 final String label; 29 28 final bool isSelected;
+2 -3
lib/src/features/posting/ui/widgets/permission_requrest.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 4 4 class CameraPermissionRequest extends StatelessWidget { 5 + const CameraPermissionRequest({required this.onRequestPermission, super.key}); 5 6 final VoidCallback onRequestPermission; 6 - 7 - const CameraPermissionRequest({super.key, required this.onRequestPermission}); 8 7 9 8 @override 10 9 Widget build(BuildContext context) { 11 - return Container( 10 + return ColoredBox( 12 11 color: Colors.black, 13 12 child: Center( 14 13 child: Column(
+1 -2
lib/src/features/posting/ui/widgets/recording_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 class RecordingBar extends StatelessWidget { 4 + const RecordingBar({required this.isRecording, required this.progress, required this.timeText, super.key}); 4 5 final bool isRecording; 5 6 final double progress; // 0.0 to 1.0 6 7 final String timeText; 7 - 8 - const RecordingBar({super.key, required this.isRecording, required this.progress, required this.timeText}); 9 8 10 9 @override 11 10 Widget build(BuildContext context) {
+3 -4
lib/src/features/posting/ui/widgets/video_preview_player.dart
··· 1 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:video_player/video_player.dart'; 3 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 4 5 5 class VideoPreviewPlayer extends StatefulWidget { 6 + const VideoPreviewPlayer({required this.controller, super.key}); 6 7 final VideoPlayerController controller; 7 - 8 - const VideoPreviewPlayer({super.key, required this.controller}); 9 8 10 9 @override 11 10 State<VideoPreviewPlayer> createState() => _VideoPreviewPlayerState(); ··· 70 69 children: [ 71 70 AspectRatio(aspectRatio: widget.controller.value.aspectRatio, child: VideoPlayer(widget.controller)), 72 71 if (_showControls) 73 - Container( 72 + ColoredBox( 74 73 color: Colors.black.withAlpha(100), 75 74 child: Center( 76 75 child: IconButton(
+1 -2
lib/src/features/posting/ui/widgets/video_thumbnail.dart
··· 5 5 import 'package:video_player/video_player.dart'; 6 6 7 7 class VideoThumbnail extends StatefulWidget { 8 + const VideoThumbnail({required this.controller, super.key}); 8 9 final VideoPlayerController controller; 9 - 10 - const VideoThumbnail({super.key, required this.controller}); 11 10 12 11 @override 13 12 State<VideoThumbnail> createState() => _VideoThumbnailState();
+5 -5
lib/src/features/profile/providers/edit_profile_provider.dart
··· 1 1 import 'dart:typed_data'; 2 2 3 3 import 'package:atproto/core.dart'; 4 + import 'package:get_it/get_it.dart'; 5 + import 'package:image_picker/image_picker.dart'; 4 6 import 'package:riverpod_annotation/riverpod_annotation.dart'; 7 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 5 8 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 6 9 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 11 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 8 12 import 'package:sparksocial/src/features/profile/providers/edit_profile_state.dart'; 9 - import 'package:get_it/get_it.dart'; 10 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 11 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 12 - import 'package:image_picker/image_picker.dart'; 13 13 import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 14 14 15 15 part 'edit_profile_provider.g.dart'; ··· 99 99 // Ensure the 'avatar' field exists and is a Map before converting to Blob. 100 100 // If it's null, it means the user had no avatar on the record. 101 101 if (recordData['avatar'] is Map<String, dynamic>) { 102 - avatarToSend = Blob.fromJson(recordData['avatar']); 102 + avatarToSend = Blob.fromJson(recordData['avatar'] as Map<String, dynamic>); 103 103 logger.d('Blob avatar: $avatarToSend'); 104 104 } else { 105 105 // This case handles an inconsistency where localAvatar was a string (URL),
+1 -1
lib/src/features/profile/providers/edit_profile_state.dart
··· 21 21 initialAvatar: profile.avatar, 22 22 localAvatar: profile.avatar, 23 23 ); 24 - } 24 + }
+9 -11
lib/src/features/profile/providers/profile_feed_provider.dart
··· 1 1 import 'dart:collection'; 2 2 3 + import 'package:atproto/atproto.dart'; 3 4 import 'package:atproto_core/atproto_core.dart'; 4 5 import 'package:get_it/get_it.dart'; 5 6 import 'package:riverpod_annotation/riverpod_annotation.dart'; 7 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 6 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 7 9 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 10 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; ··· 10 12 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 13 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 12 14 import 'package:sparksocial/src/features/profile/providers/profile_feed_state.dart'; 13 - import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 14 - import 'package:atproto/atproto.dart'; 15 15 16 16 part 'profile_feed_provider.g.dart'; 17 17 18 - typedef _FeedSourceFetcher = Future<({List<FeedViewPost> posts, String? cursor})> Function(String? cursor); 19 - 20 18 @riverpod 21 19 class ProfileFeed extends _$ProfileFeed { 22 20 late final FeedRepository _feedRepository; ··· 30 28 _feedRepository = GetIt.instance<SprkRepository>().feed; 31 29 _sqlCache = GetIt.instance<SQLCacheInterface>(); 32 30 _settingsRepository = GetIt.instance<SettingsRepository>(); 33 - _logger = GetIt.instance<LogService>().getLogger('ProfileFeed ${profileUri.toString()}'); 31 + _logger = GetIt.instance<LogService>().getLogger('ProfileFeed $profileUri'); 34 32 35 33 try { 36 34 final result = await _loadUnifiedFeed( ··· 66 64 final newPosts = <PostView>[]; 67 65 68 66 final sparkResult = await _fetchFromSource( 69 - (cursor) => _feedRepository.getAuthorFeed(profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor, bluesky: false), 67 + (cursor) => _feedRepository.getAuthorFeed(profileUri, limit: ProfileFeedState.fetchLimit, cursor: cursor), 70 68 sparkCursor, 71 69 'Sprk', 72 70 ); ··· 127 125 128 126 // Filter by video/non-video type first 129 127 final typeFilteredPosts = videosOnly 130 - ? allPosts.where((uri) => postTypes[uri] == true).toList() 128 + ? allPosts.where((uri) => postTypes[uri] ?? true).toList() 131 129 : allPosts.where((uri) => postTypes[uri] == false).toList(); 132 130 133 131 // Then filter based on label preferences ··· 152 150 } 153 151 154 152 Future<({List<FeedViewPost> posts, String? cursor})> _fetchFromSource( 155 - _FeedSourceFetcher fetcher, 153 + Future<({List<FeedViewPost> posts, String? cursor})> Function(String? cursor) fetcher, 156 154 String? cursor, 157 155 String sourceName, 158 156 ) async { ··· 182 180 } 183 181 184 182 Future<void> loadMore() async { 185 - if (_isLoading || state.value?.isEndOfNetwork == true) return; 183 + if (_isLoading || (state.value?.isEndOfNetwork ?? true)) return; 186 184 187 185 _isLoading = true; 188 186 final currentState = state.value; ··· 239 237 try { 240 238 final labelPreference = await _settingsRepository.getLabelPreference(label.value); 241 239 if (labelPreference.setting == Setting.hide || (labelPreference.adultOnly && hideAdultContent)) { 242 - _logger.d('Hiding post ${uri.toString()} due to label: ${label.value}'); 240 + _logger.d('Hiding post $uri due to label: ${label.value}'); 243 241 return true; 244 242 } 245 243 } catch (e) { ··· 270 268 271 269 // Add self labels from the post record 272 270 if (postView.record.selfLabels != null) { 273 - for (SelfLabel selfLabel in postView.record.selfLabels!) { 271 + for (final selfLabel in postView.record.selfLabels!) { 274 272 postLabels.add( 275 273 Label( 276 274 uri: postView.uri.toString(),
+2 -2
lib/src/features/profile/providers/profile_feed_state.dart
··· 9 9 10 10 @freezed 11 11 abstract class ProfileFeedState with _$ProfileFeedState { 12 - const ProfileFeedState._(); 13 12 const factory ProfileFeedState({ 14 13 required List<AtUri> loadedPosts, 15 14 required bool isEndOfNetwork, ··· 21 20 @Default(<AtUri, PostView>{}) Map<AtUri, PostView> postViews, 22 21 @Default(null) String? blueskyCursor, 23 22 }) = _ProfileFeedState; 23 + const ProfileFeedState._(); 24 24 25 25 int get length => loadedPosts.length; 26 26 int get allPostsLength => allPosts.length; 27 27 28 - List<AtUri> get videoPosts => allPosts.where((uri) => postTypes[uri] == true).toList(); 28 + List<AtUri> get videoPosts => allPosts.where((uri) => postTypes[uri] ?? true).toList(); 29 29 List<AtUri> get imagePosts => allPosts.where((uri) => postTypes[uri] == false).toList(); 30 30 31 31 static const int fetchLimit = 16; // number of posts to fetch at a time
+10 -11
lib/src/features/profile/providers/profile_provider.dart
··· 2 2 3 3 import 'package:atproto/atproto.dart' as atp; 4 4 import 'package:atproto_core/atproto_core.dart'; 5 - import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 5 import 'package:get_it/get_it.dart'; 6 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 7 7 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 8 8 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 9 - import 'package:sparksocial/src/features/profile/providers/profile_state.dart'; 10 9 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 10 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 12 11 import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 12 + import 'package:sparksocial/src/features/profile/providers/profile_state.dart'; 13 13 14 14 part 'profile_provider.g.dart'; 15 15 16 16 @riverpod 17 17 class ProfileNotifier extends _$ProfileNotifier { 18 - late final AuthRepository authRepository; 19 - late final ActorRepository actorRepository; 20 - late final SprkRepository sprkRepository; 21 - late final SparkLogger logger; 22 - 23 18 ProfileNotifier() { 24 19 authRepository = GetIt.instance<AuthRepository>(); 25 20 actorRepository = GetIt.instance<ActorRepository>(); 26 21 sprkRepository = GetIt.instance<SprkRepository>(); 27 22 logger = GetIt.instance<LogService>().getLogger('ProfileNotifier'); 28 23 } 24 + late final AuthRepository authRepository; 25 + late final ActorRepository actorRepository; 26 + late final SprkRepository sprkRepository; 27 + late final SparkLogger logger; 29 28 30 29 @override 31 30 Future<ProfileState> build({String? did}) async { ··· 36 35 } 37 36 38 37 Future<void> loadProfileData(String? targetDidArgument, ProfileState currentState) async { 39 - final String? effectiveDid = targetDidArgument ?? authRepository.session?.did; 38 + final effectiveDid = targetDidArgument ?? authRepository.session?.did; 40 39 41 40 if (!authRepository.isAuthenticated && effectiveDid == null) { 42 41 logger.i('User not authenticated and no DID provided, showing auth prompt.'); ··· 56 55 57 56 logger.d('Profile loaded successfully for $effectiveDid: ${profile.handle}'); 58 57 59 - final bool isEarlySupporter = await actorRepository.isEarlySupporter(effectiveDid); 58 + final isEarlySupporter = await actorRepository.isEarlySupporter(effectiveDid); 60 59 logger.d('Early supporter status for $effectiveDid: $isEarlySupporter'); 61 60 62 61 state = AsyncData( ··· 175 174 } catch (e, s) { 176 175 logger.e('Error toggling follow for ${profile.did}', error: e, stackTrace: s); 177 176 state = AsyncData(originalStateValue); 178 - throw Exception('Failed to toggle follow: ${e.toString()}'); 177 + throw Exception('Failed to toggle follow: $e'); 179 178 } 180 179 } 181 180 ··· 197 196 return result; 198 197 } catch (e, s) { 199 198 logger.e('Error creating report for $did', error: e, stackTrace: s); 200 - throw Exception('Failed to create report: ${e.toString()}'); 199 + throw Exception('Failed to create report: $e'); 201 200 } 202 201 } 203 202
+8 -9
lib/src/features/profile/ui/pages/edit_profile_page.dart
··· 1 1 import 'dart:typed_data'; 2 + 2 3 import 'package:auto_route/auto_route.dart'; 3 4 import 'package:flutter/material.dart'; 4 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 6 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 6 - import 'package:sparksocial/src/features/profile/providers/edit_profile_provider.dart'; 7 7 import 'package:sparksocial/src/core/widgets/custom_text_field.dart'; 8 + import 'package:sparksocial/src/features/profile/providers/edit_profile_provider.dart'; 8 9 9 10 /// Edit profile page that allows users to update their profile information 10 11 @RoutePage() 11 12 class EditProfilePage extends ConsumerStatefulWidget { 13 + const EditProfilePage({required this.profile, super.key}); 12 14 final ProfileViewDetailed profile; 13 - 14 - const EditProfilePage({super.key, required this.profile}); 15 15 16 16 @override 17 17 ConsumerState<EditProfilePage> createState() => _EditProfilePageState(); ··· 77 77 context.router.pop(); 78 78 } catch (e) { 79 79 if (!mounted) return; 80 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating profile: ${e.toString()}'))); 80 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating profile: $e'))); 81 81 } finally { 82 82 if (mounted) { 83 83 setState(() { ··· 127 127 } 128 128 } 129 129 130 - final bool hasLocalAvatar = 131 - editProfileState.localAvatar != null && editProfileState.localAvatar != editProfileState.initialAvatar; 130 + final hasLocalAvatar = editProfileState.localAvatar != null && editProfileState.localAvatar != editProfileState.initialAvatar; 132 131 133 132 return Scaffold( 134 133 backgroundColor: theme.scaffoldBackgroundColor, ··· 153 152 alignment: Alignment.bottomRight, 154 153 children: [ 155 154 GestureDetector( 156 - onTap: () => editProfileNotifier.pickAvatar(), 155 + onTap: editProfileNotifier.pickAvatar, 157 156 child: CircleAvatar( 158 157 radius: 50, 159 158 backgroundImage: avatarImageProvider, ··· 171 170 children: [ 172 171 if (hasLocalAvatar) 173 172 Padding( 174 - padding: const EdgeInsets.all(4.0), 173 + padding: const EdgeInsets.all(4), 175 174 child: GestureDetector( 176 - onTap: () => editProfileNotifier.revertAvatar(), 175 + onTap: editProfileNotifier.revertAvatar, 177 176 child: Container( 178 177 decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle), 179 178 padding: const EdgeInsets.all(4),
+16 -17
lib/src/features/profile/ui/pages/profile_page.dart
··· 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:get_it/get_it.dart'; 5 6 import 'package:sparksocial/src/core/routing/app_router.dart'; // For EditProfileRoute, LoginRoute 6 7 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 8 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 8 9 import 'package:sparksocial/src/core/widgets/menu_action_button.dart'; 9 10 import 'package:sparksocial/src/core/widgets/report_dialog.dart'; 10 11 import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 11 - import 'package:sparksocial/src/features/profile/ui/widgets/profile_header.dart'; 12 12 import 'package:sparksocial/src/features/profile/ui/widgets/early_supporter_sheet.dart'; 13 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_header.dart'; 13 14 import 'package:sparksocial/src/features/profile/ui/widgets/profile_tabs.dart'; 14 - import 'package:get_it/get_it.dart'; 15 15 16 16 @RoutePage() 17 17 class ProfilePage extends ConsumerStatefulWidget { 18 + const ProfilePage({@PathParam('did') required this.did, super.key}); 18 19 final String did; 19 - 20 - const ProfilePage({@PathParam('did') required this.did, super.key}); 21 20 22 21 @override 23 22 ConsumerState<ProfilePage> createState() => _ProfilePageState(); ··· 37 36 context: context, 38 37 isScrollControlled: true, 39 38 backgroundColor: Colors.transparent, 40 - builder: (context) => SafeArea( 41 - child: Padding(padding: const EdgeInsets.only(top: 20), child: EarlySupporterSheet()), 39 + builder: (context) => const SafeArea( 40 + child: Padding(padding: EdgeInsets.only(top: 20), child: EarlySupporterSheet()), 42 41 ), 43 42 ); 44 43 } ··· 53 52 return profileStateAsync.when( 54 53 data: (state) { 55 54 if (state.showAuthPrompt) { 56 - context.router.push(AuthPromptRoute(onClose: () => notifier.hideAuthPrompt())); 55 + context.router.push(AuthPromptRoute(onClose: notifier.hideAuthPrompt)); 57 56 } 58 57 59 58 final profile = state.profile; ··· 65 64 context: context, 66 65 message: 'Profile not found', 67 66 stackTrace: null, 68 - onRetry: () => notifier.refreshProfile(), 67 + onRetry: notifier.refreshProfile, 69 68 theme: theme, 70 69 ); 71 70 } 72 - final bool isCurrentUser = notifier.isCurrentUser(); 71 + final isCurrentUser = notifier.isCurrentUser(); 73 72 74 73 return AutoTabsRouter( 75 74 routes: [ ··· 94 93 actions: [ 95 94 if (isCurrentUser) 96 95 Padding( 97 - padding: const EdgeInsets.only(right: 8.0), 96 + padding: const EdgeInsets.only(right: 8), 98 97 child: IconButton( 99 98 padding: EdgeInsets.zero, 100 99 onPressed: () { 101 - context.router.push(ProfileSettingsRoute()); 100 + context.router.push(const ProfileSettingsRoute()); 102 101 }, 103 102 icon: Icon(FluentIcons.options_24_regular, color: Theme.of(context).colorScheme.onSurface), 104 103 ), 105 104 ) 106 105 else 107 106 Padding( 108 - padding: const EdgeInsets.only(right: 8.0), 107 + padding: const EdgeInsets.only(right: 8), 109 108 child: MenuActionButton( 110 109 // Assuming this widget is fine 111 110 onPressed: () => showDialog( ··· 152 151 ), 153 152 body: SafeArea( 154 153 child: RefreshIndicator( 155 - onRefresh: () => notifier.refreshProfile(), 154 + onRefresh: notifier.refreshProfile, 156 155 child: CustomScrollView( 157 156 key: PageStorageKey<String>('profile_${widget.did}'), // Use the passed did 158 157 slivers: [ ··· 204 203 if (context.mounted) { 205 204 ScaffoldMessenger.of( 206 205 context, 207 - ).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'), backgroundColor: Colors.red)); 206 + ).showSnackBar(SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red)); 208 207 } 209 208 } 210 209 }, ··· 215 214 delegate: StickyTabBarDelegate( 216 215 child: ProfileTabs( 217 216 selectedIndex: tabsRouter.activeIndex, 218 - onTabSelected: (index) => tabsRouter.setActiveIndex(index), 217 + onTabSelected: tabsRouter.setActiveIndex, 219 218 isAuthenticated: isCurrentUser, 220 219 ), 221 220 ), ··· 239 238 context: context, 240 239 message: error.toString(), 241 240 stackTrace: stackTrace, 242 - onRetry: () => notifier.refreshProfile(), 241 + onRetry: notifier.refreshProfile, 243 242 theme: theme, 244 243 ), 245 244 ); ··· 248 247 249 248 class ErrorScreen extends StatelessWidget { 250 249 const ErrorScreen({ 251 - super.key, 252 250 required this.context, 253 251 required this.message, 254 252 required this.stackTrace, 255 253 required this.onRetry, 256 254 required this.theme, 255 + super.key, 257 256 }); 258 257 259 258 final BuildContext context;
+2 -3
lib/src/features/profile/ui/pages/profile_photos_page.dart
··· 6 6 7 7 @RoutePage() 8 8 class ProfilePhotosPage extends ConsumerWidget { 9 + const ProfilePhotosPage({@PathParam('did') required this.did, super.key}); 9 10 final String did; 10 - 11 - const ProfilePhotosPage({@PathParam('did') required this.did, super.key}); 12 11 13 12 @override 14 13 Widget build(BuildContext context, WidgetRef ref) { ··· 17 16 videosOnly: false, 18 17 ); 19 18 } 20 - } 19 + }
+2 -3
lib/src/features/profile/ui/pages/profile_videos_page.dart
··· 6 6 7 7 @RoutePage() 8 8 class ProfileVideosPage extends ConsumerWidget { 9 + const ProfileVideosPage({@PathParam('did') required this.did, super.key}); 9 10 final String did; 10 - 11 - const ProfileVideosPage({@PathParam('did') required this.did, super.key}); 12 11 13 12 @override 14 13 Widget build(BuildContext context, WidgetRef ref) { ··· 17 16 videosOnly: true, 18 17 ); 19 18 } 20 - } 19 + }
+6 -8
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 1 - import 'package:auto_route/auto_route.dart'; 2 1 import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; ··· 9 9 10 10 @RoutePage() 11 11 class StandaloneProfileFeedPage extends ConsumerStatefulWidget { 12 - final String profileUri; 13 - final bool videosOnly; 14 - final int initialPostIndex; 15 - 16 12 const StandaloneProfileFeedPage({ 17 - super.key, 18 13 required this.profileUri, 19 14 required this.videosOnly, 20 15 required this.initialPostIndex, 16 + super.key, 21 17 }); 18 + final String profileUri; 19 + final bool videosOnly; 20 + final int initialPostIndex; 22 21 23 22 @override 24 23 ConsumerState<StandaloneProfileFeedPage> createState() => _StandaloneProfileFeedPageState(); ··· 47 46 48 47 return Scaffold( 49 48 backgroundColor: AppColors.black, 50 - appBar: AppBar(backgroundColor: AppColors.black, leading: AutoLeadingButton()), 49 + appBar: AppBar(backgroundColor: AppColors.black, leading: const AutoLeadingButton()), 51 50 body: feedState.when( 52 51 data: (state) { 53 52 if (state.loadedPosts.isEmpty) { ··· 71 70 return CacheablePageView.builder( 72 71 controller: pageController, 73 72 scrollDirection: Axis.vertical, 74 - pageSnapping: true, 75 73 itemCount: state.loadedPosts.length, 76 74 onPageChanged: (index) { 77 75 // Load more posts when approaching the end
+11 -9
lib/src/features/profile/ui/widgets/auth_required_content.dart
··· 2 2 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 3 4 4 class AuthRequiredContent extends StatelessWidget { 5 - final String title; 6 - final String description; 7 - final IconData icon; 8 - final VoidCallback onLoginPressed; 9 - 10 5 const AuthRequiredContent({ 11 - super.key, 12 6 required this.title, 13 7 required this.description, 14 8 required this.icon, 15 9 required this.onLoginPressed, 10 + super.key, 16 11 }); 12 + final String title; 13 + final String description; 14 + final IconData icon; 15 + final VoidCallback onLoginPressed; 17 16 18 17 @override 19 18 Widget build(BuildContext context) { 20 - final ThemeData theme = Theme.of(context); 19 + final theme = Theme.of(context); 21 20 22 21 return SliverFillRemaining( 23 22 hasScrollBody: false, 24 23 child: Center( 25 24 child: Padding( 26 - padding: const EdgeInsets.symmetric(horizontal: 24.0), 25 + padding: const EdgeInsets.symmetric(horizontal: 24), 27 26 child: Column( 28 27 mainAxisAlignment: MainAxisAlignment.center, 29 28 children: [ ··· 52 51 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 53 52 ), 54 53 onPressed: onLoginPressed, 55 - child: const Text('Login', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.white)), 54 + child: const Text( 55 + 'Login', 56 + style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.white), 57 + ), 56 58 ), 57 59 ], 58 60 ),
+10 -4
lib/src/features/profile/ui/widgets/early_supporter_sheet.dart
··· 7 7 8 8 @override 9 9 Widget build(BuildContext context) { 10 - final ThemeData theme = Theme.of(context); 11 - final bool isDarkMode = theme.brightness == Brightness.dark; 10 + final theme = Theme.of(context); 11 + final isDarkMode = theme.brightness == Brightness.dark; 12 12 13 13 return Container( 14 14 padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), ··· 32 32 colorFilter: const ColorFilter.mode(AppColors.primary, BlendMode.srcIn), 33 33 ), 34 34 const SizedBox(height: 16), 35 - const Text('Early supporter', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: AppColors.primary)), 35 + const Text( 36 + 'Early supporter', 37 + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: AppColors.primary), 38 + ), 36 39 const SizedBox(height: 24), 37 40 Text.rich( 38 41 TextSpan( ··· 54 57 style: TextStyle(fontSize: 16, color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600), 55 58 children: const [ 56 59 TextSpan(text: 'Thanks to them, '), 57 - TextSpan(text: 'Spark is a reality', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold)), 60 + TextSpan( 61 + text: 'Spark is a reality', 62 + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold), 63 + ), 58 64 TextSpan(text: '.'), 59 65 ], 60 66 ),
+4 -4
lib/src/features/profile/ui/widgets/profile_avatar_editor.dart
··· 3 3 import 'package:cached_network_image/cached_network_image.dart'; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 6 - import 'package:sparksocial/src/features/profile/providers/edit_profile_state.dart'; 7 6 import 'package:sparksocial/src/features/profile/providers/edit_profile_provider.dart'; 7 + import 'package:sparksocial/src/features/profile/providers/edit_profile_state.dart'; 8 8 9 9 /// Widget for editing the profile avatar 10 10 class ProfileAvatarEditor extends StatefulWidget { 11 + /// Creates a profile avatar editor 12 + const ProfileAvatarEditor({required this.state, required this.notifier, super.key}); 13 + 11 14 /// Current state of the profile being edited 12 15 final EditProfileState state; 13 16 14 17 /// The notifier to trigger actions on the profile 15 18 final EditProfile notifier; 16 - 17 - /// Creates a profile avatar editor 18 - const ProfileAvatarEditor({super.key, required this.state, required this.notifier}); 19 19 20 20 @override 21 21 State<ProfileAvatarEditor> createState() => _ProfileAvatarEditorState();
+10 -12
lib/src/features/profile/ui/widgets/profile_content_thumbnail.dart
··· 1 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 1 2 import 'package:flutter/material.dart'; 2 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 + import 'package:get_it/get_it.dart'; 3 4 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 - import 'package:sparksocial/src/core/utils/logging/logger.dart'; 5 5 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 6 - import 'package:get_it/get_it.dart'; 7 6 8 7 class ProfileContentThumbnail extends StatelessWidget { 9 - final int index; 10 - final Color backgroundColor; 11 - final IconData icon; 12 - final String viewCount; 13 - final String? duration; 14 - 15 8 const ProfileContentThumbnail({ 16 - super.key, 17 9 required this.index, 18 10 required this.backgroundColor, 19 11 required this.icon, 20 12 required this.viewCount, 13 + super.key, 21 14 this.duration, 22 15 }); 16 + final int index; 17 + final Color backgroundColor; 18 + final IconData icon; 19 + final String viewCount; 20 + final String? duration; 23 21 24 22 @override 25 23 Widget build(BuildContext context) { 26 - final SparkLogger logger = GetIt.instance<LogService>().getLogger('ProfileContentThumbnail'); 24 + final logger = GetIt.instance<LogService>().getLogger('ProfileContentThumbnail'); 27 25 28 26 return GestureDetector( 29 27 onTap: () { 30 28 logger.d('Content clicked at index $index'); 31 29 }, 32 - child: Container( 30 + child: ColoredBox( 33 31 color: backgroundColor, 34 32 child: Stack( 35 33 children: [
+21 -23
lib/src/features/profile/ui/widgets/profile_description.dart
··· 3 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 5 5 class ProfileDescription extends StatefulWidget { 6 - final String text; 7 - final TextStyle? style; 8 - final int maxLines; 9 - final Function(bool isExpanded)? onExpandToggle; 10 - final Function(String username)? onMentionTap; 11 - 12 6 const ProfileDescription({ 13 - super.key, 14 7 required this.text, 8 + super.key, 15 9 this.style, 16 10 this.maxLines = 2, 17 11 this.onExpandToggle, 18 12 this.onMentionTap, 19 13 }); 14 + final String text; 15 + final TextStyle? style; 16 + final int maxLines; 17 + final Function(bool isExpanded)? onExpandToggle; 18 + final Function(String username)? onMentionTap; 20 19 21 20 @override 22 21 State<ProfileDescription> createState() => _ProfileDescriptionState(); ··· 33 32 _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); 34 33 35 34 _scaleAnimation = TweenSequence<double>([ 36 - TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.03), weight: 30), 37 - TweenSequenceItem(tween: Tween<double>(begin: 1.03, end: 1.0), weight: 70), 35 + TweenSequenceItem(tween: Tween<double>(begin: 1, end: 1.03), weight: 30), 36 + TweenSequenceItem(tween: Tween<double>(begin: 1.03, end: 1), weight: 70), 38 37 ]).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut)); 39 38 } 40 39 ··· 60 59 } 61 60 62 61 List<Match> _findUsernameMatches(String text) { 63 - final RegExp usernameRegex = RegExp(r'\B@([a-zA-Z0-9_.-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9_]+)', caseSensitive: false); 62 + final usernameRegex = RegExp(r'\B@([a-zA-Z0-9_.-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9_]+)', caseSensitive: false); 64 63 return usernameRegex.allMatches(text).toList(); 65 64 } 66 65 67 66 List<InlineSpan> _buildTextSpans(String text, List<Match> usernameMatches, TextStyle defaultStyle) { 68 - final List<InlineSpan> spans = []; 69 - int lastEnd = 0; 67 + final spans = <InlineSpan>[]; 68 + var lastEnd = 0; 70 69 71 70 usernameMatches.sort((a, b) => a.start.compareTo(b.start)); 72 71 ··· 75 74 spans.add(TextSpan(text: text.substring(lastEnd, match.start))); // Default style is applied by RichText 76 75 } 77 76 78 - final String username = match.group(0)!; 77 + final username = match.group(0)!; 79 78 spans.add( 80 79 TextSpan( 81 80 text: username, 82 81 style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold), 83 - recognizer: 84 - TapGestureRecognizer() 85 - ..onTap = () { 86 - if (widget.onMentionTap != null) { 87 - widget.onMentionTap!(username); 88 - } 89 - }, 82 + recognizer: TapGestureRecognizer() 83 + ..onTap = () { 84 + if (widget.onMentionTap != null) { 85 + widget.onMentionTap!(username); 86 + } 87 + }, 90 88 ), 91 89 ); 92 90 lastEnd = match.end; ··· 100 98 101 99 @override 102 100 Widget build(BuildContext context) { 103 - final ThemeData theme = Theme.of(context); 101 + final theme = Theme.of(context); 104 102 final usernameMatches = _findUsernameMatches(widget.text); 105 - final TextStyle defaultStyle = 103 + final defaultStyle = 106 104 widget.style ?? TextStyle(color: theme.textTheme.bodyMedium?.color ?? theme.colorScheme.onSurface, fontSize: 14); 107 105 108 - final TextSpan textSpan = TextSpan( 106 + final textSpan = TextSpan( 109 107 children: _buildTextSpans(widget.text, usernameMatches, defaultStyle), 110 108 style: defaultStyle, // Apply default style to the parent TextSpan 111 109 );
+9 -10
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 1 - import 'package:auto_route/auto_route.dart'; 2 1 import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:get_it/get_it.dart'; ··· 8 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 9 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 10 10 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 11 + import 'package:sparksocial/src/core/utils/label_utils.dart'; 12 + import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 13 + import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 11 14 import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 12 15 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 13 16 import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 14 17 import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 15 18 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 16 - import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 17 - import 'package:sparksocial/src/core/utils/label_utils.dart'; 18 - import 'package:sparksocial/src/core/widgets/content_warning_overlay.dart'; 19 19 20 20 class ProfileFeedPostWidget extends ConsumerStatefulWidget { 21 + const ProfileFeedPostWidget({required this.postUri, required this.profileUri, required this.videosOnly, super.key, this.post}); 21 22 final AtUri postUri; 22 23 final AtUri profileUri; 23 24 final bool videosOnly; 24 25 final PostView? post; 25 - 26 - const ProfileFeedPostWidget({super.key, required this.postUri, required this.profileUri, required this.videosOnly, this.post}); 27 26 28 27 @override 29 28 ConsumerState<ProfileFeedPostWidget> createState() => _ProfileFeedPostWidgetState(); ··· 149 148 future: _loadPostWithFallback(), 150 149 builder: (context, snapshot) { 151 150 if (snapshot.connectionState == ConnectionState.waiting) { 152 - return Container( 151 + return const ColoredBox( 153 152 color: AppColors.black, 154 - child: const Center(child: CircularProgressIndicator(color: AppColors.white)), 153 + child: Center(child: CircularProgressIndicator(color: AppColors.white)), 155 154 ); 156 155 } 157 156 158 157 if (snapshot.hasError || !snapshot.hasData) { 159 - return Container( 158 + return const ColoredBox( 160 159 color: AppColors.black, 161 - child: const Center( 160 + child: Center( 162 161 child: Column( 163 162 mainAxisAlignment: MainAxisAlignment.center, 164 163 children: [
+8 -10
lib/src/features/profile/ui/widgets/profile_grid_widget.dart
··· 14 14 import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 15 15 16 16 class ProfileGridWidget extends ConsumerStatefulWidget { 17 + const ProfileGridWidget({required this.profileUri, required this.videosOnly, super.key}); 17 18 final AtUri profileUri; 18 19 final bool videosOnly; 19 - 20 - const ProfileGridWidget({super.key, required this.profileUri, required this.videosOnly}); 21 20 22 21 @override 23 22 ConsumerState<ProfileGridWidget> createState() => _ProfileGridWidgetState(); ··· 84 83 itemCount: state.loadedPosts.length + (state.isEndOfNetwork ? 0 : 1), 85 84 itemBuilder: (context, index) { 86 85 if (index >= state.loadedPosts.length) { 87 - return Container( 86 + return ColoredBox( 88 87 color: Theme.of(context).colorScheme.surfaceContainerHighest, 89 88 child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), 90 89 ); ··· 145 144 } 146 145 147 146 class ProfileGridTile extends StatefulWidget { 147 + const ProfileGridTile({required this.postView, required this.onTap, super.key, this.postSource}); 148 148 final PostView postView; 149 149 final String? postSource; 150 150 final VoidCallback onTap; 151 - 152 - const ProfileGridTile({super.key, required this.postView, this.postSource, required this.onTap}); 153 151 154 152 @override 155 153 State<ProfileGridTile> createState() => _ProfileGridTileState(); ··· 189 187 imageUrl: thumbnailUrl, 190 188 fit: BoxFit.cover, 191 189 placeholder: (context, url) => const SizedBox.shrink(), 192 - errorWidget: (context, url, error) => Container( 190 + errorWidget: (context, url, error) => ColoredBox( 193 191 color: Theme.of(context).colorScheme.surfaceContainerHighest, 194 192 child: const Center(child: Icon(FluentIcons.error_circle_24_regular, size: 20)), 195 193 ), 196 194 ) 197 - : Container( 195 + : ColoredBox( 198 196 color: Theme.of(context).colorScheme.surfaceContainerHighest, 199 197 child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 20)), 200 198 ); 201 199 202 200 return GestureDetector( 203 201 onTap: widget.onTap, 204 - child: Container( 202 + child: ColoredBox( 205 203 color: AppColors.black, 206 204 child: thumbnailUrl.isNotEmpty 207 205 ? Stack( ··· 209 207 children: [ 210 208 if (_shouldBlur) 211 209 ImageFiltered( 212 - imageFilter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), 210 + imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), 213 211 child: image, 214 212 ) 215 213 else ··· 230 228 ), 231 229 ], 232 230 ) 233 - : Container( 231 + : ColoredBox( 234 232 color: Theme.of(context).colorScheme.surfaceContainerHighest, 235 233 child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 20)), 236 234 ),
+35 -45
lib/src/features/profile/ui/widgets/profile_header.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_svg/flutter_svg.dart'; 6 6 import 'package:get_it/get_it.dart'; 7 - 7 + import 'package:sparksocial/src/core/auth/data/repositories/identity_repository.dart'; 8 8 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart' as actor_models; 9 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 10 import 'package:sparksocial/src/core/routing/app_router.dart'; 10 11 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 12 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 13 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 14 import 'package:sparksocial/src/core/utils/text_formatter.dart'; 12 15 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 13 - import 'package:sparksocial/src/core/auth/data/repositories/identity_repository.dart'; 14 - import 'package:sparksocial/src/core/utils/logging/logger.dart'; 15 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 16 - import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 17 - 18 16 // Local imports for other profile widgets that will be migrated 19 - import 'profile_description.dart'; 20 - import 'profile_links.dart'; // Placeholder will be created 21 - import 'profile_stat_item.dart'; // Placeholder will be created 17 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_description.dart'; 18 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_links.dart'; // Placeholder will be created 19 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_stat_item.dart'; // Placeholder will be created 22 20 23 21 class ProfileHeader extends StatefulWidget { 24 - final actor_models.ProfileViewDetailed profile; 25 - final bool isCurrentUser; 26 - final bool isEarlySupporter; 27 - final VoidCallback onEarlySupporterTap; 28 - final VoidCallback onEditTap; 29 - final VoidCallback onShareTap; 30 - final VoidCallback onFollowTap; 31 - 32 22 const ProfileHeader({ 33 - super.key, 34 23 required this.profile, 35 24 required this.isCurrentUser, 36 - this.isEarlySupporter = false, 37 25 required this.onEarlySupporterTap, 38 26 required this.onEditTap, 39 27 required this.onShareTap, 40 28 required this.onFollowTap, 29 + super.key, 30 + this.isEarlySupporter = false, 41 31 }); 32 + final actor_models.ProfileViewDetailed profile; 33 + final bool isCurrentUser; 34 + final bool isEarlySupporter; 35 + final VoidCallback onEarlySupporterTap; 36 + final VoidCallback onEditTap; 37 + final VoidCallback onShareTap; 38 + final VoidCallback onFollowTap; 42 39 43 40 @override 44 41 State<ProfileHeader> createState() => _ProfileHeaderState(); ··· 59 56 60 57 Future<void> _handleUsernameTap(String username) async { 61 58 try { 62 - final String cleanUsername = username.startsWith('@') ? username.substring(1) : username; 59 + final cleanUsername = username.startsWith('@') ? username.substring(1) : username; 63 60 _logger.d('Username clicked: $cleanUsername'); 64 61 65 - final String? didRes = await _identityRepository.resolveHandleToDid(cleanUsername); 62 + final didRes = await _identityRepository.resolveHandleToDid(cleanUsername); 66 63 if (didRes == null) { 67 64 _logger.w('Could not resolve handle to DID for $cleanUsername'); 68 65 return; ··· 79 76 if (!(widget.profile.stories?.isNotEmpty ?? false)) return; 80 77 81 78 try { 82 - final storyUris = widget.profile.stories! 83 - .map((strongRef) => strongRef.uri) 84 - .toList(); 79 + final storyUris = widget.profile.stories!.map((strongRef) => strongRef.uri).toList(); 85 80 86 81 if (storyUris.isEmpty) return; 87 82 final stories = await _sprkRepository.feed.getStoryViews(storyUris); ··· 103 98 104 99 if (mounted) { 105 100 context.router.push( 106 - AllStoriesRoute(storiesByAuthor: {authorBasic: stories}, initialAuthorIndex: 0), 101 + AllStoriesRoute(storiesByAuthor: {authorBasic: stories}), 107 102 ); 108 103 } 109 104 } catch (e, s) { ··· 113 108 114 109 @override 115 110 Widget build(BuildContext context) { 116 - final ThemeData theme = Theme.of(context); 117 - final bool isDarkMode = theme.brightness == Brightness.dark; 111 + final theme = Theme.of(context); 112 + final isDarkMode = theme.brightness == Brightness.dark; 118 113 119 114 // Determine if the profile has any stories associated with it. 120 - final bool hasStories = (widget.profile.stories?.isNotEmpty ?? false); 115 + final hasStories = widget.profile.stories?.isNotEmpty ?? false; 121 116 122 117 final String displayNameForAvatar; 123 118 if (widget.profile.displayName case final String dn when dn.isNotEmpty) { ··· 129 124 final Widget avatarWidget; 130 125 if (widget.profile.avatar case final AtUri av when av.toString().isNotEmpty) { 131 126 avatarWidget = ClipOval( 132 - child: UserAvatar(imageUrl: av.toString(), username: displayNameForAvatar, size: 90, borderWidth: 0), 127 + child: UserAvatar(imageUrl: av.toString(), username: displayNameForAvatar, size: 90), 133 128 ); 134 129 } else { 135 130 avatarWidget = Icon( ··· 146 141 headerDisplayName = widget.profile.handle; 147 142 } 148 143 149 - final String handle = widget.profile.handle; 150 - final String description = widget.profile.description ?? ''; 144 + final handle = widget.profile.handle; 145 + final description = widget.profile.description ?? ''; 151 146 152 - final String postsCount = TextFormatter.formatCount(widget.profile.postsCount); 153 - final String followersCount = TextFormatter.formatCount(widget.profile.followersCount); 154 - final String followsCount = TextFormatter.formatCount(widget.profile.followsCount); 147 + final postsCount = TextFormatter.formatCount(widget.profile.postsCount); 148 + final followersCount = TextFormatter.formatCount(widget.profile.followersCount); 149 + final followsCount = TextFormatter.formatCount(widget.profile.followsCount); 155 150 156 - final List<String> links = TextFormatter.extractUrls(description); 157 - final List<String> uniqueLinks = links.toSet().toList(); 151 + final links = TextFormatter.extractUrls(description); 152 + final uniqueLinks = links.toSet().toList(); 158 153 159 154 return Padding( 160 - padding: const EdgeInsets.all(16.0), 155 + padding: const EdgeInsets.all(16), 161 156 child: Column( 162 157 crossAxisAlignment: CrossAxisAlignment.start, 163 158 children: [ 164 159 Row( 165 - crossAxisAlignment: CrossAxisAlignment.center, 166 160 children: [ 167 161 Stack( 168 162 children: [ ··· 183 177 : BoxDecoration( 184 178 color: isDarkMode ? AppColors.darkPurple : AppColors.lightLavender, 185 179 shape: BoxShape.circle, 186 - border: Border.all( 187 - color: isDarkMode ? AppColors.darkPurple : AppColors.lightLavender, width: 2), 180 + border: Border.all(color: isDarkMode ? AppColors.darkPurple : AppColors.lightLavender, width: 2), 188 181 ), 189 182 child: hasStories 190 183 ? Container( ··· 209 202 color: AppColors.primary, 210 203 border: Border.all(color: isDarkMode ? AppColors.deepPurple : AppColors.white, width: 2), 211 204 ), 212 - child: 213 - const Center(child: Icon(FluentIcons.add_24_filled, size: 18, color: AppColors.white)), 205 + child: const Center(child: Icon(FluentIcons.add_24_filled, size: 18, color: AppColors.white)), 214 206 ), 215 207 ), 216 208 ), ··· 272 264 ), 273 265 if (uniqueLinks.isNotEmpty) 274 266 Padding( 275 - padding: const EdgeInsets.only(top: 4.0), 267 + padding: const EdgeInsets.only(top: 4), 276 268 child: ProfileLinks(links: uniqueLinks), 277 269 ), 278 270 ], ··· 281 273 children: [ 282 274 if (widget.isCurrentUser) ...[ 283 275 Expanded( 284 - flex: 1, 285 276 child: Container( 286 277 constraints: const BoxConstraints(minHeight: 36), 287 278 child: ElevatedButton( ··· 300 291 const SizedBox(width: 8), 301 292 ] else ...[ 302 293 Expanded( 303 - flex: 1, 304 294 child: Container( 305 295 constraints: const BoxConstraints(minHeight: 36), 306 296 child: ElevatedButton(
+10 -14
lib/src/features/profile/ui/widgets/profile_links.dart
··· 1 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 1 2 import 'package:flutter/material.dart'; 3 + import 'package:get_it/get_it.dart'; 2 4 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 - import 'package:sparksocial/src/core/utils/logging/logger.dart'; 5 5 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 6 - import 'package:get_it/get_it.dart'; 7 6 8 7 // Placeholder for ProfileLinks widget 9 8 class ProfileLinks extends StatelessWidget { 10 - final List<String> links; 11 - 12 9 const ProfileLinks({required this.links, super.key}); 10 + final List<String> links; 13 11 14 12 @override 15 13 Widget build(BuildContext context) { 16 - final SparkLogger logger = GetIt.instance<LogService>().getLogger('ProfileLinks'); 14 + final logger = GetIt.instance<LogService>().getLogger('ProfileLinks'); 17 15 18 16 logger.d('Building ProfileLinks with ${links.length} links: $links'); 19 17 ··· 22 20 } 23 21 24 22 return Container( 25 - margin: const EdgeInsets.only(top: 6.0), 23 + margin: const EdgeInsets.only(top: 6), 26 24 child: Column( 27 25 crossAxisAlignment: CrossAxisAlignment.start, 28 26 children: links.map((url) => _ProfileLinkItem(url: url)).toList(), ··· 32 30 } 33 31 34 32 class _ProfileLinkItem extends StatelessWidget { 33 + const _ProfileLinkItem({required this.url}); 35 34 final String url; 36 - 37 - const _ProfileLinkItem({required this.url}); 38 35 39 36 @override 40 37 Widget build(BuildContext context) { 41 38 // Use theme colors if possible, or keep AppColors.blue if it's a specific brand blue 42 - final Color linkColor = AppColors.blue; // Or: Theme.of(context).colorScheme.primary; 39 + const linkColor = AppColors.blue; // Or: Theme.of(context).colorScheme.primary; 43 40 44 41 return Padding( 45 - padding: const EdgeInsets.only(top: 4.0, bottom: 2.0), 42 + padding: const EdgeInsets.only(top: 4, bottom: 2), 46 43 child: Row( 47 - crossAxisAlignment: CrossAxisAlignment.center, 48 44 children: [ 49 - Icon(FluentIcons.link_24_regular, size: 16, color: linkColor), 45 + const Icon(FluentIcons.link_24_regular, size: 16, color: linkColor), 50 46 const SizedBox(width: 6), 51 47 Expanded( 52 48 child: Text( 53 49 url, 54 - style: TextStyle(color: linkColor, fontWeight: FontWeight.bold, fontSize: 14), 50 + style: const TextStyle(color: linkColor, fontWeight: FontWeight.bold, fontSize: 14), 55 51 overflow: TextOverflow.ellipsis, 56 52 ), 57 53 ),
+28 -30
lib/src/features/profile/ui/widgets/profile_save_button.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 5 - import 'package:sparksocial/src/features/profile/providers/edit_profile_state.dart'; 6 5 import 'package:sparksocial/src/features/profile/providers/edit_profile_provider.dart'; 6 + import 'package:sparksocial/src/features/profile/providers/edit_profile_state.dart'; 7 7 8 8 /// Button widget for saving profile changes 9 9 class ProfileSaveButton extends StatelessWidget { 10 + /// Creates a profile save button 11 + const ProfileSaveButton({required this.state, required this.notifier, required this.formKey, required this.logger, super.key}); 12 + 10 13 /// Current state of the profile being edited 11 14 final EditProfileState state; 12 15 ··· 18 21 19 22 /// Logger for error reporting 20 23 final SparkLogger logger; 21 - 22 - /// Creates a profile save button 23 - const ProfileSaveButton({super.key, required this.state, required this.notifier, required this.formKey, required this.logger}); 24 24 25 25 @override 26 26 Widget build(BuildContext context) { 27 27 return SizedBox( 28 28 width: double.infinity, 29 29 child: ElevatedButton( 30 - onPressed: 31 - state.isSaving 32 - ? null 33 - : () async { 34 - if (!formKey.currentState!.validate()) return; 30 + onPressed: state.isSaving 31 + ? null 32 + : () async { 33 + if (!formKey.currentState!.validate()) return; 35 34 36 - try { 37 - final result = await notifier.saveProfile(); 38 - if (result && context.mounted) { 39 - context.router.maybePop(true); 40 - } else if (context.mounted) { 41 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Failed to update profile'))); 42 - } 43 - } catch (e) { 44 - logger.e('Error saving profile', error: e); 45 - if (context.mounted) { 46 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating profile: $e'))); 47 - } 35 + try { 36 + final result = await notifier.saveProfile(); 37 + if (result && context.mounted) { 38 + context.router.maybePop(true); 39 + } else if (context.mounted) { 40 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Failed to update profile'))); 48 41 } 49 - }, 42 + } catch (e) { 43 + logger.e('Error saving profile', error: e); 44 + if (context.mounted) { 45 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating profile: $e'))); 46 + } 47 + } 48 + }, 50 49 style: ElevatedButton.styleFrom( 51 50 backgroundColor: AppColors.primary, 52 51 foregroundColor: Colors.white, 53 52 padding: const EdgeInsets.symmetric(vertical: 12), 54 53 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 55 54 ), 56 - child: 57 - state.isSaving 58 - ? const SizedBox( 59 - width: 24, 60 - height: 24, 61 - child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white), strokeWidth: 2), 62 - ) 63 - : const Text('Save', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 55 + child: state.isSaving 56 + ? const SizedBox( 57 + width: 24, 58 + height: 24, 59 + child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(Colors.white), strokeWidth: 2), 60 + ) 61 + : const Text('Save', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), 64 62 ), 65 63 ); 66 64 }
+2 -2
lib/src/features/profile/ui/widgets/profile_stat_item.dart
··· 2 2 3 3 // Placeholder for ProfileStatItem widget 4 4 class ProfileStatItem extends StatelessWidget { 5 + const ProfileStatItem({required this.count, required this.label, super.key}); 5 6 final String count; 6 7 final String label; 7 - const ProfileStatItem({super.key, required this.count, required this.label}); 8 8 9 9 @override 10 10 Widget build(BuildContext context) { 11 - final ThemeData theme = Theme.of(context); 11 + final theme = Theme.of(context); 12 12 return Column( 13 13 mainAxisSize: MainAxisSize.min, 14 14 mainAxisAlignment: MainAxisAlignment.center,
+10 -11
lib/src/features/profile/ui/widgets/profile_tabs.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 4 4 class ProfileTabs extends StatelessWidget { 5 + // This might be handled by a provider later if tabs change based on auth state 6 + 7 + const ProfileTabs({required this.selectedIndex, required this.onTabSelected, required this.isAuthenticated, super.key}); 5 8 final int selectedIndex; 6 9 final Function(int) onTabSelected; 7 - final bool isAuthenticated; // This might be handled by a provider later if tabs change based on auth state 8 - 9 - const ProfileTabs({super.key, required this.selectedIndex, required this.onTabSelected, required this.isAuthenticated}); 10 + final bool isAuthenticated; 10 11 11 12 @override 12 13 Widget build(BuildContext context) { 13 - final ThemeData theme = Theme.of(context); 14 + final theme = Theme.of(context); 14 15 return Container( 15 16 decoration: BoxDecoration( 16 17 color: theme.colorScheme.surface, // Updated color ··· 49 50 } 50 51 51 52 class _ProfileTabItemWidget extends StatelessWidget { 53 + const _ProfileTabItemWidget({required this.icon, required this.filledIcon, required this.isSelected, required this.onTap}); 52 54 final IconData icon; 53 55 final IconData filledIcon; 54 56 final bool isSelected; 55 57 final VoidCallback onTap; 56 58 57 - const _ProfileTabItemWidget({required this.icon, required this.filledIcon, required this.isSelected, required this.onTap}); 58 - 59 59 @override 60 60 Widget build(BuildContext context) { 61 - final ThemeData theme = Theme.of(context); 62 - final Color iconColor = isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; 61 + final theme = Theme.of(context); 62 + final iconColor = isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; 63 63 64 64 return Expanded( 65 65 // Ensures tabs take equal space ··· 84 84 } 85 85 86 86 class StickyTabBarDelegate extends SliverPersistentHeaderDelegate { 87 + StickyTabBarDelegate({required this.child, this.height = 50.0}); 87 88 final Widget child; 88 89 final double height; 89 - 90 - StickyTabBarDelegate({required this.child, this.height = 50.0}); 91 90 92 91 @override 93 92 Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { ··· 105 104 106 105 @override 107 106 bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { 108 - if (oldDelegate case StickyTabBarDelegate delegate) { 107 + if (oldDelegate case final StickyTabBarDelegate delegate) { 109 108 return delegate.height != height || delegate.child != child; 110 109 } 111 110 return true;
+16 -13
lib/src/features/profile/ui/widgets/profile_text_field.dart
··· 3 3 4 4 /// A customized text field widget for profile editing 5 5 class ProfileTextField extends StatefulWidget { 6 + /// Creates a profile text field 7 + const ProfileTextField({ 8 + required this.initialValue, 9 + required this.hintText, 10 + required this.onChanged, 11 + required this.bgColor, 12 + super.key, 13 + this.maxLines = 1, 14 + }); 15 + 6 16 /// Initial value of the text field 7 17 final String initialValue; 8 18 ··· 17 27 18 28 /// Number of lines for the text field 19 29 final int maxLines; 20 - 21 - /// Creates a profile text field 22 - const ProfileTextField({ 23 - super.key, 24 - required this.initialValue, 25 - required this.hintText, 26 - required this.onChanged, 27 - required this.bgColor, 28 - this.maxLines = 1, 29 - }); 30 30 31 31 @override 32 32 State<ProfileTextField> createState() => _ProfileTextFieldState(); ··· 65 65 hintText: widget.hintText, 66 66 filled: true, 67 67 fillColor: widget.bgColor, 68 - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: AppColors.border)), 68 + border: OutlineInputBorder( 69 + borderRadius: BorderRadius.circular(8), 70 + borderSide: const BorderSide(color: AppColors.border), 71 + ), 69 72 enabledBorder: OutlineInputBorder( 70 73 borderRadius: BorderRadius.circular(8), 71 - borderSide: BorderSide(color: AppColors.border), 74 + borderSide: const BorderSide(color: AppColors.border), 72 75 ), 73 76 focusedBorder: OutlineInputBorder( 74 77 borderRadius: BorderRadius.circular(8), 75 - borderSide: BorderSide(color: AppColors.primary), 78 + borderSide: const BorderSide(color: AppColors.primary), 76 79 ), 77 80 ), 78 81 );
+29 -34
lib/src/features/profile/ui/widgets/profile_video_tile.dart
··· 1 - import 'package:flutter/material.dart'; 1 + import 'package:cached_network_image/cached_network_image.dart'; 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 + import 'package:flutter/material.dart'; 3 4 import 'package:flutter_svg/flutter_svg.dart'; 4 - import 'package:cached_network_image/cached_network_image.dart'; 5 5 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 6 6 7 7 class ProfileVideoTile extends StatelessWidget { 8 - final String? videoUrl; 9 - final String? thumbnailUrl; 10 - final String username; 11 - final String description; 12 - final List<String> hashtags; 13 - final int index; 14 - final int likeCount; 15 - final VoidCallback onTap; 16 - final bool isSprk; 17 - final bool isImage; 18 - 19 8 const ProfileVideoTile({ 20 - super.key, 21 9 required this.videoUrl, 22 - this.thumbnailUrl, 23 10 required this.username, 24 11 required this.description, 25 12 required this.hashtags, 26 13 required this.index, 27 - this.likeCount = 0, 28 14 required this.onTap, 15 + super.key, 16 + this.thumbnailUrl, 17 + this.likeCount = 0, 29 18 this.isSprk = false, 30 19 this.isImage = false, 31 20 }); 21 + final String? videoUrl; 22 + final String? thumbnailUrl; 23 + final String username; 24 + final String description; 25 + final List<String> hashtags; 26 + final int index; 27 + final int likeCount; 28 + final VoidCallback onTap; 29 + final bool isSprk; 30 + final bool isImage; 32 31 33 32 @override 34 33 Widget build(BuildContext context) { ··· 37 36 thumbnailWidget = CachedNetworkImage( 38 37 imageUrl: url, 39 38 fit: BoxFit.cover, 40 - alignment: Alignment.center, 41 39 fadeInDuration: const Duration(milliseconds: 200), 42 - placeholder: 43 - (context, url) => Container( 44 - color: Colors.black, 45 - child: const Center(child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), 46 - ), 47 - errorWidget: 48 - (context, url, error) => Container( 49 - color: Colors.black, 50 - child: const Center(child: Icon(FluentIcons.video_24_regular, color: Colors.white, size: 24)), 51 - ), 40 + placeholder: (context, url) => const ColoredBox( 41 + color: Colors.black, 42 + child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), 43 + ), 44 + errorWidget: (context, url, error) => const ColoredBox( 45 + color: Colors.black, 46 + child: Center(child: Icon(FluentIcons.video_24_regular, color: Colors.white, size: 24)), 47 + ), 52 48 ); 53 49 } else { 54 50 thumbnailWidget = Center(child: Icon(FluentIcons.video_24_regular, color: AppColors.white.withAlpha(204), size: 24)); ··· 56 52 57 53 return GestureDetector( 58 54 onTap: onTap, 59 - child: Container( 55 + child: ColoredBox( 60 56 color: AppColors.richPurple.withAlpha(120), 61 57 child: Stack( 62 58 fit: StackFit.expand, ··· 81 77 decoration: BoxDecoration( 82 78 borderRadius: BorderRadius.circular(42), 83 79 boxShadow: [ 84 - BoxShadow(color: AppColors.black.withAlpha(30), blurRadius: 4, spreadRadius: 1, offset: const Offset(0, 0)), 80 + BoxShadow(color: AppColors.black.withAlpha(30), blurRadius: 4, spreadRadius: 1), 85 81 ], 86 82 ), 87 83 child: Icon( ··· 99 95 decoration: BoxDecoration( 100 96 borderRadius: BorderRadius.circular(42), 101 97 boxShadow: [ 102 - BoxShadow(color: AppColors.black.withAlpha(30), blurRadius: 4, spreadRadius: 1, offset: const Offset(0, 0)), 98 + BoxShadow(color: AppColors.black.withAlpha(30), blurRadius: 4, spreadRadius: 1), 103 99 ], 104 100 ), 105 - child: 106 - isSprk 107 - ? SvgPicture.asset('assets/images/sprk.svg', width: 14, height: 14) 108 - : SvgPicture.asset('assets/images/bsky.svg', width: 14, height: 14), 101 + child: isSprk 102 + ? SvgPicture.asset('assets/images/sprk.svg', width: 14, height: 14) 103 + : SvgPicture.asset('assets/images/bsky.svg', width: 14, height: 14), 109 104 ), 110 105 ), 111 106 ],
+7 -6
lib/src/features/search/providers/search_provider.dart
··· 3 3 import 'package:atproto_core/atproto_core.dart'; 4 4 import 'package:get_it/get_it.dart'; 5 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 6 7 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 7 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository.dart'; 8 9 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository.dart'; 9 10 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 12 import 'package:sparksocial/src/features/search/providers/search_state.dart'; 12 13 13 14 part 'search_provider.g.dart'; ··· 16 17 @riverpod 17 18 class Search extends _$Search { 18 19 Timer? _debounce; 19 - final _logger = GetIt.instance<LogService>().getLogger('SearchProvider'); 20 - final _actorRepository = GetIt.instance<ActorRepository>(); 21 - final _authRepository = GetIt.instance<AuthRepository>(); 22 - final _graphRepository = GetIt.instance<GraphRepository>(); 20 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('SearchProvider'); 21 + final ActorRepository _actorRepository = GetIt.instance<ActorRepository>(); 22 + final AuthRepository _authRepository = GetIt.instance<AuthRepository>(); 23 + final GraphRepository _graphRepository = GetIt.instance<GraphRepository>(); 23 24 24 25 @override 25 26 SearchState build() { ··· 146 147 if (userIndex != -1) { 147 148 final user = updatedResults[userIndex]; 148 149 149 - final updatedUser = user.copyWith(viewer: ActorViewer(following: null)); 150 + final updatedUser = user.copyWith(viewer: const ActorViewer()); 150 151 151 152 updatedResults[userIndex] = updatedUser; 152 153 state = state.copyWith(searchResults: updatedResults);
+7 -7
lib/src/features/search/ui/pages/search_page.dart
··· 6 6 import 'package:sparksocial/src/core/routing/app_router.dart'; 7 7 import 'package:sparksocial/src/features/search/providers/search_provider.dart'; 8 8 import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; 9 - import 'package:sparksocial/src/features/stories/ui/widgets/stories_list.dart'; 10 9 import 'package:sparksocial/src/features/stories/providers/stories_by_author.dart'; 10 + import 'package:sparksocial/src/features/stories/ui/widgets/stories_list.dart'; 11 11 12 12 /// Search page to find users 13 13 @RoutePage() ··· 53 53 crossAxisAlignment: CrossAxisAlignment.start, 54 54 children: [ 55 55 Padding( 56 - padding: const EdgeInsets.all(16.0), 56 + padding: const EdgeInsets.all(16), 57 57 child: TextField( 58 58 controller: _searchController, 59 59 decoration: InputDecoration( ··· 80 80 borderRadius: BorderRadius.circular(8), 81 81 borderSide: BorderSide(color: colorScheme.outline), 82 82 ), 83 - contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), 83 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 84 84 ), 85 85 ), 86 86 ), ··· 90 90 child: RefreshIndicator( 91 91 onRefresh: () async { 92 92 // Refresh the stories timeline 93 - ref.invalidate(storiesByAuthorProvider(limit: 30, cursor: null)); 93 + ref.invalidate(storiesByAuthorProvider()); 94 94 }, 95 95 child: CustomScrollView( 96 96 slivers: [ 97 - SliverToBoxAdapter(child: StoriesList()), 97 + const SliverToBoxAdapter(child: StoriesList()), 98 98 SliverFillRemaining( 99 99 hasScrollBody: false, 100 100 child: Center( ··· 131 131 unselectedLabelColor: theme.textTheme.bodyMedium?.color, 132 132 ), 133 133 ), 134 - Expanded(child: TabBarView(children: [const UserResults()])), 134 + const Expanded(child: TabBarView(children: [UserResults()])), 135 135 ], 136 136 ], 137 137 ), ··· 197 197 if (index >= state.searchResults.length) { 198 198 // Loading indicator at bottom 199 199 return const Padding( 200 - padding: EdgeInsets.all(16.0), 200 + padding: EdgeInsets.all(16), 201 201 child: Center(child: CircularProgressIndicator()), 202 202 ); 203 203 }
+1 -2
lib/src/features/search/ui/widgets/category_chip.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 class CategoryChip extends StatelessWidget { 4 + const CategoryChip({required this.label, super.key, this.onTap, this.isSelected = false}); 4 5 final String label; 5 6 final VoidCallback? onTap; 6 7 final bool isSelected; 7 - 8 - const CategoryChip({super.key, required this.label, this.onTap, this.isSelected = false}); 9 8 10 9 @override 11 10 Widget build(BuildContext context) {
+1 -2
lib/src/features/search/ui/widgets/section_header.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 3 3 class SectionHeader extends StatelessWidget { 4 + const SectionHeader({required this.title, super.key, this.onViewAllTap, this.icon}); 4 5 final String title; 5 6 final VoidCallback? onViewAllTap; 6 7 final IconData? icon; 7 - 8 - const SectionHeader({super.key, required this.title, this.onViewAllTap, this.icon}); 9 8 10 9 @override 11 10 Widget build(BuildContext context) {
+2 -3
lib/src/features/search/ui/widgets/sound_card.dart
··· 1 - import 'package:flutter/material.dart'; 2 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 3 4 4 class SoundCard extends StatelessWidget { 5 + const SoundCard({required this.title, required this.artist, required this.imageUrl, super.key, this.onTap}); 5 6 final String title; 6 7 final String artist; 7 8 final String imageUrl; 8 9 final VoidCallback? onTap; 9 - 10 - const SoundCard({super.key, required this.title, required this.artist, required this.imageUrl, this.onTap}); 11 10 12 11 @override 13 12 Widget build(BuildContext context) {
+8 -9
lib/src/features/search/ui/widgets/story_circle.dart
··· 1 - import 'package:flutter/material.dart'; 2 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 4 5 5 class StoryCircle extends StatelessWidget { 6 - final String username; 7 - final String imageUrl; 8 - final bool isLive; 9 - final bool isYourStory; 10 - final VoidCallback? onTap; 11 - 12 6 const StoryCircle({ 13 - super.key, 14 7 required this.username, 15 8 required this.imageUrl, 9 + super.key, 16 10 this.isLive = false, 17 11 this.isYourStory = false, 18 12 this.onTap, 19 13 }); 14 + final String username; 15 + final String imageUrl; 16 + final bool isLive; 17 + final bool isYourStory; 18 + final VoidCallback? onTap; 20 19 21 20 @override 22 21 Widget build(BuildContext context) { ··· 63 62 decoration: BoxDecoration( 64 63 color: colorScheme.primary, 65 64 shape: BoxShape.circle, 66 - border: Border.all(color: AppColors.black, width: 1.5), 65 + border: Border.all(width: 1.5), 67 66 ), 68 67 child: const Icon(FluentIcons.add_24_regular, size: 16, color: Colors.white), 69 68 ),
+11 -12
lib/src/features/search/ui/widgets/suggested_account_card.dart
··· 3 3 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 4 4 5 5 class SuggestedAccountCard extends StatelessWidget { 6 - final String username; 7 - final String handle; 8 - final String avatarUrl; 9 - final String? description; 10 - final VoidCallback? onTap; 11 - final VoidCallback? onFollowTap; 12 - final VoidCallback? onUnfollowTap; 13 - final bool showFollowButton; 14 - final bool isFollowing; 15 - 16 6 const SuggestedAccountCard({ 17 - super.key, 18 7 required this.username, 19 8 required this.handle, 20 9 required this.avatarUrl, 10 + super.key, 21 11 this.description, 22 12 this.onTap, 23 13 this.onFollowTap, ··· 25 15 this.showFollowButton = true, 26 16 this.isFollowing = false, 27 17 }); 18 + final String username; 19 + final String handle; 20 + final String avatarUrl; 21 + final String? description; 22 + final VoidCallback? onTap; 23 + final VoidCallback? onFollowTap; 24 + final VoidCallback? onUnfollowTap; 25 + final bool showFollowButton; 26 + final bool isFollowing; 28 27 29 28 @override 30 29 Widget build(BuildContext context) { ··· 67 66 ), 68 67 if (description != null && description!.isNotEmpty) 69 68 Padding( 70 - padding: const EdgeInsets.only(top: 2.0), 69 + padding: const EdgeInsets.only(top: 2), 71 70 child: Text( 72 71 description!, 73 72 style: TextStyle(fontSize: 13, color: secondaryTextColor),
+1 -2
lib/src/features/search/ui/widgets/trending_video_card.dart
··· 4 4 import 'package:sparksocial/src/core/utils/text_formatter.dart'; 5 5 6 6 class TrendingVideoCard extends StatelessWidget { 7 + const TrendingVideoCard({required this.thumbnailUrl, required this.viewCount, super.key, this.onTap}); 7 8 final String thumbnailUrl; 8 9 final int viewCount; 9 10 final VoidCallback? onTap; 10 - 11 - const TrendingVideoCard({super.key, required this.thumbnailUrl, required this.viewCount, this.onTap}); 12 11 13 12 @override 14 13 Widget build(BuildContext context) {
+8 -16
lib/src/features/settings/providers/settings_provider.dart
··· 1 1 import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 2 import 'package:get_it/get_it.dart'; 3 3 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 + import 'package:sparksocial/src/core/storage/preferences/settings_repository.dart'; 4 6 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 5 7 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 8 + import 'package:sparksocial/src/features/settings/providers/settings_state.dart'; 6 9 import 'package:sparksocial/src/features/settings/ui/pages/profile_settings_page.dart'; 7 - import 'settings_state.dart'; 8 - import '../../../core/storage/preferences/settings_repository.dart'; 9 - import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 10 10 11 11 part 'settings_provider.g.dart'; 12 12 ··· 29 29 30 30 // Load settings asynchronously but return a temporary state immediately 31 31 // This prevents blocking the UI while loading 32 - Future.microtask(() => loadSettings()); 32 + Future.microtask(loadSettings); 33 33 34 34 // Return temporary default state that will be replaced by loadSettings() 35 - return SettingsState( 35 + return const SettingsState( 36 36 activeFeed: Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 37 - feedBlurEnabled: false, 38 - hideAdultContent: true, 39 - followMode: FollowMode.sprk, 40 - feeds: [ 41 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.following), 42 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.forYou), 43 - Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 44 - ], 45 - postToBskyEnabled: false, 46 37 ); 47 38 } 48 39 ··· 137 128 138 129 /// Reorders a feed in feeds list 139 130 Future<void> reorderFeed(int oldIndex, int newIndex) async { 131 + var actualNewIndex = newIndex; 140 132 if (newIndex == state.feeds.length) { 141 - newIndex = state.feeds.length - 1; 133 + actualNewIndex = state.feeds.length - 1; 142 134 } 143 135 final updatedList = [...state.feeds]; 144 136 final feed = updatedList.removeAt(oldIndex); 145 - updatedList.insert(newIndex, feed); 137 + updatedList.insert(actualNewIndex, feed); 146 138 await _repository.setFeeds(updatedList); 147 139 state = state.copyWith(feeds: updatedList); 148 140 }
+1 -1
lib/src/features/settings/providers/settings_state.dart
··· 8 8 @freezed 9 9 class SettingsState with _$SettingsState { 10 10 const factory SettingsState({ 11 + required Feed activeFeed, 11 12 @Default(false) bool feedBlurEnabled, 12 13 @Default(true) bool hideAdultContent, 13 14 @Default(FollowMode.sprk) FollowMode followMode, ··· 17 18 Feed.hardCoded(hardCodedFeed: HardCodedFeedEnum.latestSprk), 18 19 ]) 19 20 List<Feed> feeds, 20 - required Feed activeFeed, 21 21 @Default(false) bool postToBskyEnabled, 22 22 }) = _SettingsState; 23 23 }
+15 -14
lib/src/features/settings/ui/pages/feed_list_page.dart
··· 1 + import 'dart:ui' show lerpDouble; 2 + 1 3 import 'package:auto_route/auto_route.dart'; 4 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 5 import 'package:flutter/material.dart'; 3 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 7 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 8 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 6 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 7 - import 'dart:ui' show lerpDouble; 8 9 9 10 @RoutePage() 10 11 class FeedListPage extends ConsumerStatefulWidget { ··· 132 133 }, 133 134 onReorder: (oldIndex, newIndex) async { 134 135 if (_isReordering) return; 135 - 136 + 136 137 setState(() => _isReordering = true); 137 - 138 + 138 139 try { 139 140 // Adjust newIndex if moving down the list 140 141 if (newIndex > oldIndex) newIndex -= 1; 141 - 142 + 142 143 await ref.read(settingsProvider.notifier).reorderFeed(oldIndex, newIndex); 143 - 144 + 144 145 // Small delay to allow state to settle 145 146 await Future.delayed(const Duration(milliseconds: 50)); 146 147 } catch (e) { ··· 162 163 final animValue = Curves.easeInOutCubic.transform(animation.value); 163 164 final elevation = lerpDouble(2, 8, animValue)!; 164 165 final scale = lerpDouble(1, 1.05, animValue)!; 165 - 166 + 166 167 return Transform.scale( 167 168 scale: scale, 168 169 child: Material( ··· 216 217 ), 217 218 const SizedBox(width: 8), 218 219 Icon( 219 - FluentIcons.re_order_dots_vertical_24_regular, 220 - color: _isReordering 221 - ? colorScheme.primary.withAlpha(128) 222 - : colorScheme.onSurface.withAlpha(178), 220 + FluentIcons.re_order_dots_vertical_24_regular, 221 + color: _isReordering ? colorScheme.primary.withAlpha(128) : colorScheme.onSurface.withAlpha(178), 223 222 ), 224 223 ], 225 224 ), 226 - onTap: _isReordering ? null : () { 227 - ref.read(settingsProvider.notifier).setActiveFeed(feed); 228 - }, 225 + onTap: _isReordering 226 + ? null 227 + : () { 228 + ref.read(settingsProvider.notifier).setActiveFeed(feed); 229 + }, 229 230 ), 230 231 ); 231 232 },
+3 -5
lib/src/features/settings/ui/pages/feed_settings_page.dart
··· 12 12 ConsumerState<FeedSettingsPage> createState() => _FeedSettingsPageState(); 13 13 } 14 14 15 - class _FeedSettingsPageState extends ConsumerState<FeedSettingsPage> 16 - with SingleTickerProviderStateMixin { 15 + class _FeedSettingsPageState extends ConsumerState<FeedSettingsPage> with SingleTickerProviderStateMixin { 17 16 late TabController _tabController; 18 17 19 18 @override ··· 45 44 controller: _tabController, 46 45 labelColor: textColor, 47 46 unselectedLabelColor: textColor.withAlpha(127), 48 - isScrollable: false, 49 47 tabs: const [ 50 - Tab(text: "Your Feeds"), 51 - Tab(text: "Content Labels"), 48 + Tab(text: 'Your Feeds'), 49 + Tab(text: 'Content Labels'), 52 50 ], 53 51 ), 54 52 ),
+5 -6
lib/src/features/settings/ui/pages/label_settings_page.dart
··· 35 35 setState(() => _isLoading = true); 36 36 37 37 final followedLabelers = await _settingsRepository.getFollowedLabelers(); 38 - final Map<String, LabelPreference> preferences = {}; 38 + final preferences = <String, LabelPreference>{}; 39 39 40 40 _logger.d('Loading preferences for ${defaultLabels.length} default labels'); 41 41 ··· 346 346 } 347 347 348 348 class LabelSettingTile extends StatelessWidget { 349 - final String label; 350 - final LabelPreference preference; 351 - final Function(String label, {Setting? setting, Blurs? blurs, Severity? severity}) onPreferenceUpdate; 352 - 353 349 const LabelSettingTile({ 354 - super.key, 355 350 required this.label, 356 351 required this.preference, 357 352 required this.onPreferenceUpdate, 353 + super.key, 358 354 }); 355 + final String label; 356 + final LabelPreference preference; 357 + final Function(String label, {Setting? setting, Blurs? blurs, Severity? severity}) onPreferenceUpdate; 359 358 360 359 @override 361 360 Widget build(BuildContext context) {
+7 -7
lib/src/features/settings/ui/pages/profile_settings_page.dart
··· 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 - import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 6 - import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 7 5 import 'package:sparksocial/src/core/routing/app_router.dart'; 6 + import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 7 + import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 8 8 9 9 enum FollowMode { sprk, bsky } 10 10 ··· 47 47 // Show error message 48 48 ScaffoldMessenger.of(context).showSnackBar( 49 49 SnackBar( 50 - content: Text('Logout failed: ${e.toString()}'), 50 + content: Text('Logout failed: $e'), 51 51 backgroundColor: Colors.red, 52 52 ), 53 53 ); ··· 65 65 final brightness = Theme.of(context).brightness; 66 66 final isDark = brightness == Brightness.dark; 67 67 final itemColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200; 68 - final pinkColor = const Color(0xFFE91E63); 68 + const pinkColor = Color(0xFFE91E63); 69 69 70 70 final displayValues = _followModeMap.keys.toList(); 71 71 final modeValues = _followModeMap.values.toList(); ··· 89 89 padding: const EdgeInsets.symmetric(horizontal: 16), 90 90 children: [ 91 91 Padding( 92 - padding: const EdgeInsets.symmetric(vertical: 8.0), 92 + padding: const EdgeInsets.symmetric(vertical: 8), 93 93 child: Container( 94 94 decoration: BoxDecoration(color: itemColor, borderRadius: BorderRadius.circular(16)), 95 95 padding: const EdgeInsets.all(16), ··· 112 112 // Spark exclusive button 113 113 Expanded( 114 114 child: Padding( 115 - padding: const EdgeInsets.symmetric(horizontal: 4.0), 115 + padding: const EdgeInsets.symmetric(horizontal: 4), 116 116 child: ElevatedButton( 117 117 onPressed: () => _handleFollowModeChange(modeValues[0]), 118 118 style: ElevatedButton.styleFrom( ··· 139 139 ), 140 140 Expanded( 141 141 child: Padding( 142 - padding: const EdgeInsets.symmetric(horizontal: 4.0), 142 + padding: const EdgeInsets.symmetric(horizontal: 4), 143 143 child: ElevatedButton( 144 144 onPressed: () => _handleFollowModeChange(modeValues[1]), 145 145 style: ElevatedButton.styleFrom(
+1 -1
lib/src/features/splash/providers/splash_state.dart
··· 10 10 /// Whether the image is loaded 11 11 @Default(false) bool isImageLoaded, 12 12 }) = _SplashState; 13 - } 13 + }
+17 -14
lib/src/features/splash/ui/pages/splash_page.dart
··· 3 3 import 'package:auto_route/auto_route.dart'; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 - import 'package:flutter_native_splash/flutter_native_splash.dart'; 7 6 import 'package:get_it/get_it.dart'; 8 - import 'package:sparksocial/src/core/routing/app_router.dart'; 9 - import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 10 7 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 11 8 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository_impl.dart'; 12 9 import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository.dart'; 10 + import 'package:sparksocial/src/core/routing/app_router.dart'; 11 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 12 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 13 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 13 14 import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 14 - import 'package:sparksocial/src/features/splash/providers/splash_providers.dart'; 15 15 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 16 16 import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 17 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 17 + import 'package:sparksocial/src/features/splash/providers/splash_providers.dart'; 18 18 19 19 @RoutePage() 20 20 class SplashPage extends ConsumerStatefulWidget { ··· 25 25 } 26 26 27 27 class _SplashPageState extends ConsumerState<SplashPage> { 28 - final _logger = GetIt.instance<LogService>().getLogger('SplashPage'); 28 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('SplashPage'); 29 29 bool _isNavigating = false; 30 30 bool _hasStartedFeedLoading = false; 31 31 ··· 38 38 @override 39 39 void dispose() { 40 40 // Always remove splash screen when disposing splash page 41 - FlutterNativeSplash.remove(); 42 41 super.dispose(); 43 42 } 44 43 ··· 71 70 return; 72 71 } 73 72 74 - final bool isSessionValid = await authRepository.validateSession(); 73 + final isSessionValid = await authRepository.validateSession(); 75 74 76 75 if (!mounted) return; 77 76 ··· 173 172 void _navigateToLogin() { 174 173 if (_isNavigating || !mounted) return; 175 174 _isNavigating = true; 176 - FlutterNativeSplash.remove(); 177 175 context.router.replaceAll([const LoginRoute()]); 178 176 } 179 177 180 178 void _navigateToRegister() { 181 179 if (_isNavigating || !mounted) return; 182 180 _isNavigating = true; 183 - FlutterNativeSplash.remove(); 184 181 context.router.replaceAll([const RegisterRoute()]); 185 182 } 186 183 187 184 void _navigateToMain() { 188 185 if (_isNavigating || !mounted) return; 189 186 _isNavigating = true; 190 - FlutterNativeSplash.remove(); 191 187 context.router.replaceAll([const MainRoute()]); 192 188 } 193 189 194 190 @override 195 191 Widget build(BuildContext context) { 196 - // Show a minimal loading screen while keeping native splash active 197 - return const Scaffold( 192 + return Scaffold( 198 193 backgroundColor: AppColors.black, 199 - body: Center(child: CircularProgressIndicator(color: AppColors.white)), 194 + body: Stack( 195 + fit: StackFit.expand, 196 + children: [ 197 + Image.asset( 198 + 'assets/branding/intro.webp', 199 + fit: BoxFit.cover, 200 + ), 201 + ], 202 + ), 200 203 ); 201 204 } 202 205 }
+1 -1
lib/src/features/stories/providers/stories_by_author.dart
··· 1 1 import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 2 import 'package:get_it/get_it.dart'; 3 3 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 - import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 4 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 6 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 7 7 8 8 part 'stories_by_author.g.dart';
+9 -9
lib/src/features/stories/ui/pages/all_stories_page.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 4 4 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 5 - import 'author_stories_page.dart'; 5 + import 'package:sparksocial/src/features/stories/ui/pages/author_stories_page.dart'; 6 6 7 7 @RoutePage() 8 8 class AllStoriesPage extends StatefulWidget { 9 9 const AllStoriesPage({ 10 - super.key, 11 10 required this.storiesByAuthor, 11 + super.key, 12 12 this.initialAuthorIndex = 0, 13 13 }); 14 14 ··· 53 53 stories: entry.value, 54 54 onPreviousAuthor: index > 0 55 55 ? () => _pageController.previousPage( 56 - duration: const Duration(milliseconds: 250), 57 - curve: Curves.easeInOut, 58 - ) 56 + duration: const Duration(milliseconds: 250), 57 + curve: Curves.easeInOut, 58 + ) 59 59 : null, 60 60 onNextAuthor: index < _authorsList.length - 1 61 61 ? () => _pageController.nextPage( 62 - duration: const Duration(milliseconds: 250), 63 - curve: Curves.easeInOut, 64 - ) 62 + duration: const Duration(milliseconds: 250), 63 + curve: Curves.easeInOut, 64 + ) 65 65 : null, 66 66 ); 67 67 }, ··· 69 69 ), 70 70 ); 71 71 } 72 - } 72 + }
+8 -8
lib/src/features/stories/ui/pages/author_stories_page.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:auto_route/auto_route.dart'; 4 + import 'package:cached_network_image/cached_network_image.dart'; 2 5 import 'package:flutter/material.dart'; 3 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 7 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 5 8 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 - import 'package:cached_network_image/cached_network_image.dart'; 7 9 import 'package:sparksocial/src/core/routing/app_router.dart'; 8 - import 'dart:async'; 9 - 10 10 import 'package:sparksocial/src/features/stories/ui/pages/story_page.dart'; 11 11 12 12 @RoutePage() 13 13 class AuthorStoriesPage extends ConsumerStatefulWidget { 14 14 const AuthorStoriesPage({ 15 - super.key, 16 15 required this.author, 17 16 required this.stories, 17 + super.key, 18 18 this.initialStoryIndex = 0, 19 19 this.onPreviousAuthor, 20 20 this.onNextAuthor, ··· 40 40 late final PageController _pageController; 41 41 late final List<AnimationController> _progressControllers; 42 42 int _currentStoryIndex = 0; 43 - double _dragOffset = 0.0; 44 - double _dragScale = 1.0; 43 + double _dragOffset = 0; 44 + double _dragScale = 1; 45 45 bool _isDragging = false; 46 46 bool _isCurrentStoryLoading = true; 47 47 ··· 94 94 setState(() { 95 95 _isCurrentStoryLoading = isLoading; 96 96 }); 97 - 97 + 98 98 if (isLoading) { 99 99 _pause(); 100 100 } else { ··· 181 181 final startScale = _dragScale; 182 182 const steps = 30; 183 183 const duration = Duration(milliseconds: 10); 184 - int step = 0; 184 + var step = 0; 185 185 Timer.periodic(duration, (timer) { 186 186 step++; 187 187 final t = step / steps;
+5 -8
lib/src/features/stories/ui/pages/story_page.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 + import 'package:cached_network_image/cached_network_image.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 - import 'package:video_player/video_player.dart'; 5 - import 'package:cached_network_image/cached_network_image.dart'; 6 5 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 6 + import 'package:video_player/video_player.dart'; 7 7 8 8 @RoutePage() 9 9 class StoryPage extends ConsumerStatefulWidget { 10 10 const StoryPage({ 11 - super.key, 12 11 required this.story, 12 + super.key, 13 13 this.onLoadingStateChanged, 14 14 }); 15 15 ··· 39 39 } 40 40 41 41 void _updateLoadingState() { 42 - final bool isLoading = _isVideoStory(widget.story) 43 - ? !_isVideoInitialized 44 - : !_isImageLoaded; 42 + final isLoading = _isVideoStory(widget.story) ? !_isVideoInitialized : !_isImageLoaded; 45 43 46 44 if (_isLoading != isLoading) { 47 45 _isLoading = isLoading; ··· 140 138 mediaContent = CachedNetworkImage( 141 139 imageUrl: imageUrl, 142 140 fit: BoxFit.cover, 143 - progressIndicatorBuilder: (context, url, progress) => 144 - const Center(child: CircularProgressIndicator()), 141 + progressIndicatorBuilder: (context, url, progress) => const Center(child: CircularProgressIndicator()), 145 142 errorWidget: (context, url, error) { 146 143 // Consider error state as "loaded" to avoid infinite loading 147 144 WidgetsBinding.instance.addPostFrameCallback((_) {
+5 -5
lib/src/features/stories/ui/widgets/stories_list.dart
··· 21 21 22 22 @override 23 23 Widget build(BuildContext context) { 24 - final storiesByAuthor = ref.watch(storiesByAuthorProvider(limit: 30, cursor: _cursor)); 24 + final storiesByAuthor = ref.watch(storiesByAuthorProvider(cursor: _cursor)); 25 25 return Column( 26 26 crossAxisAlignment: CrossAxisAlignment.start, 27 27 children: [ 28 28 Padding( 29 - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), 29 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 30 30 child: Row( 31 31 children: [ 32 32 Text( ··· 47 47 final authorsList = data.storiesByAuthor.entries.toList(); 48 48 return ListView.builder( 49 49 scrollDirection: Axis.horizontal, 50 - padding: const EdgeInsets.symmetric(horizontal: 16.0), 50 + padding: const EdgeInsets.symmetric(horizontal: 16), 51 51 itemCount: authorsList.length + 1, // +1 for add story button 52 52 itemBuilder: (context, index) { 53 53 if (index == 0) { ··· 89 89 decoration: BoxDecoration( 90 90 shape: BoxShape.circle, 91 91 color: Theme.of(context).colorScheme.primary, 92 - border: Border.all(color: Colors.black, width: 2), 92 + border: Border.all(width: 2), 93 93 ), 94 94 child: const Icon(FluentIcons.add_12_regular, color: Colors.white, size: 12), 95 95 ), ··· 128 128 storiesByAuthor: data.storiesByAuthor, 129 129 initialAuthorIndex: realIndex, 130 130 ), 131 - ) 131 + ), 132 132 }, 133 133 child: Stack( 134 134 children: [
+3 -15
lib/src/sprk_app.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter/services.dart'; 2 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 - import 'package:sparksocial/src/core/di/service_locator.dart'; 4 4 import 'package:sparksocial/src/core/routing/app_router.dart'; 5 - import 'package:flutter/services.dart'; 5 + import 'package:sparksocial/src/core/theme/data/models/app_theme.dart'; 6 + import 'package:sparksocial/src/core/theme/domain/theme_provider.dart'; 6 7 7 - import 'core/theme/data/models/app_theme.dart'; 8 - import 'core/theme/domain/theme_provider.dart'; 9 - 10 - /// SprkApp is the root widget of the new architecture. 11 - /// As features are migrated, they will be integrated here. 12 8 class SprkApp extends ConsumerStatefulWidget { 13 9 const SprkApp({super.key}); 14 10 ··· 31 27 // Force dark status bar and navigation bar 32 28 SystemChrome.setSystemUIOverlayStyle(AppTheme.darkSystemUiStyle); 33 29 34 - // Watch theme mode from the provider 35 30 final themeMode = ref.watch(themeModeProvider); 36 31 37 32 return MaterialApp.router( ··· 44 39 ); 45 40 } 46 41 } 47 - 48 - /// This method configures all dependencies required for the new architecture. 49 - /// It should be called before the app starts. 50 - Future<void> configureDependencies() async { 51 - // Initialize GetIt 52 - await initServiceLocator(); 53 - }
+20 -92
pubspec.lock
··· 157 157 dependency: transitive 158 158 description: 159 159 name: build 160 - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 160 + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" 161 161 url: "https://pub.dev" 162 162 source: hosted 163 - version: "2.4.2" 163 + version: "2.5.4" 164 164 build_config: 165 165 dependency: transitive 166 166 description: ··· 181 181 dependency: transitive 182 182 description: 183 183 name: build_resolvers 184 - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 184 + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 185 185 url: "https://pub.dev" 186 186 source: hosted 187 - version: "2.4.4" 187 + version: "2.5.4" 188 188 build_runner: 189 189 dependency: "direct dev" 190 190 description: 191 191 name: build_runner 192 - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" 192 + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" 193 193 url: "https://pub.dev" 194 194 source: hosted 195 - version: "2.4.15" 195 + version: "2.5.4" 196 196 build_runner_core: 197 197 dependency: transitive 198 198 description: 199 199 name: build_runner_core 200 - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" 200 + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" 201 201 url: "https://pub.dev" 202 202 source: hosted 203 - version: "8.0.0" 203 + version: "9.1.2" 204 204 built_collection: 205 205 dependency: transitive 206 206 description: ··· 393 393 url: "https://pub.dev" 394 394 source: hosted 395 395 version: "1.0.2" 396 - cupertino_icons: 397 - dependency: "direct main" 398 - description: 399 - name: cupertino_icons 400 - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 401 - url: "https://pub.dev" 402 - source: hosted 403 - version: "1.0.8" 404 396 custom_lint_core: 405 397 dependency: transitive 406 398 description: ··· 433 425 url: "https://pub.dev" 434 426 source: hosted 435 427 version: "3.1.0" 436 - dio: 437 - dependency: "direct main" 438 - description: 439 - name: dio 440 - sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" 441 - url: "https://pub.dev" 442 - source: hosted 443 - version: "5.8.0+1" 444 - dio_web_adapter: 445 - dependency: transitive 446 - description: 447 - name: dio_web_adapter 448 - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" 449 - url: "https://pub.dev" 450 - source: hosted 451 - version: "2.1.1" 452 428 fake_async: 453 429 dependency: transitive 454 430 description: ··· 551 527 source: hosted 552 528 version: "0.18.6" 553 529 flutter_launcher_icons: 554 - dependency: "direct main" 530 + dependency: "direct dev" 555 531 description: 556 532 name: flutter_launcher_icons 557 533 sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c ··· 566 542 url: "https://pub.dev" 567 543 source: hosted 568 544 version: "6.0.0" 569 - flutter_native_splash: 570 - dependency: "direct main" 571 - description: 572 - name: flutter_native_splash 573 - sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" 574 - url: "https://pub.dev" 575 - source: hosted 576 - version: "2.4.6" 577 545 flutter_plugin_android_lifecycle: 578 546 dependency: transitive 579 547 description: ··· 642 610 dependency: "direct main" 643 611 description: 644 612 name: flutter_svg 645 - sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b 613 + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 646 614 url: "https://pub.dev" 647 615 source: hosted 648 - version: "2.0.17" 616 + version: "2.2.0" 649 617 flutter_test: 650 618 dependency: "direct dev" 651 619 description: flutter ··· 856 824 url: "https://pub.dev" 857 825 source: hosted 858 826 version: "1.0.5" 859 - ionicons: 860 - dependency: "direct main" 861 - description: 862 - name: ionicons 863 - sha256: "5496bc65a16115ecf05b15b78f494ee4a8869504357668f0a11d689e970523cf" 864 - url: "https://pub.dev" 865 - source: hosted 866 - version: "0.2.2" 867 827 js: 868 828 dependency: transitive 869 829 description: ··· 928 888 url: "https://pub.dev" 929 889 source: hosted 930 890 version: "6.0.0" 931 - logger: 932 - dependency: "direct main" 933 - description: 934 - name: logger 935 - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 936 - url: "https://pub.dev" 937 - source: hosted 938 - version: "2.5.0" 939 891 logging: 940 892 dependency: transitive 941 893 description: ··· 944 896 url: "https://pub.dev" 945 897 source: hosted 946 898 version: "1.3.0" 947 - lucide_icons_flutter: 948 - dependency: "direct main" 949 - description: 950 - name: lucide_icons_flutter 951 - sha256: "2cc30b669d2e9329072bdd4e3f50d4a31d4dd6ee9e9748d639dab95cd2edd0ce" 952 - url: "https://pub.dev" 953 - source: hosted 954 - version: "3.0.5" 955 899 matcher: 956 900 dependency: transitive 957 901 description: ··· 977 921 source: hosted 978 922 version: "1.16.0" 979 923 mime: 980 - dependency: "direct main" 924 + dependency: transitive 981 925 description: 982 926 name: mime 983 927 sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" ··· 1144 1088 url: "https://pub.dev" 1145 1089 source: hosted 1146 1090 version: "6.0.1" 1147 - preload_page_view: 1148 - dependency: "direct main" 1149 - description: 1150 - name: preload_page_view 1151 - sha256: "488a10c158c5c2e9ba9d77e5dbc09b1e49e37a20df2301e5ba02992eac802b7a" 1152 - url: "https://pub.dev" 1153 - source: hosted 1154 - version: "0.2.0" 1155 1091 pub_semver: 1156 1092 dependency: transitive 1157 1093 description: ··· 1309 1245 url: "https://pub.dev" 1310 1246 source: hosted 1311 1247 version: "0.0.4" 1312 - socket_io_client: 1313 - dependency: "direct main" 1314 - description: 1315 - name: socket_io_client 1316 - sha256: c8471c2c6843cf308a5532ff653f2bcdb7fa9ae79d84d1179920578a06624f0d 1317 - url: "https://pub.dev" 1318 - source: hosted 1319 - version: "3.1.2" 1320 - socket_io_common: 1321 - dependency: transitive 1322 - description: 1323 - name: socket_io_common 1324 - sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" 1325 - url: "https://pub.dev" 1326 - source: hosted 1327 - version: "3.1.1" 1328 1248 source_gen: 1329 1249 dependency: transitive 1330 1250 description: ··· 1629 1549 url: "https://pub.dev" 1630 1550 source: hosted 1631 1551 version: "2.1.4" 1552 + very_good_analysis: 1553 + dependency: "direct dev" 1554 + description: 1555 + name: very_good_analysis 1556 + sha256: e479fbc0941009262343db308133e121bf8660c2c81d48dd8e952df7b7e1e382 1557 + url: "https://pub.dev" 1558 + source: hosted 1559 + version: "9.0.0" 1632 1560 video_player: 1633 1561 dependency: "direct main" 1634 1562 description:
+7 -17
pubspec.yaml
··· 1 1 name: sparksocial 2 - description: "A new Flutter project." 3 - publish_to: 'none' 2 + description: "Spark mobile app" 3 + publish_to: "none" 4 4 # The number after + should stay fixed, the android workflow will change it on every build 5 5 version: 0.2.0+1 6 6 ··· 11 11 flutter: 12 12 sdk: flutter 13 13 flutter_dotenv: ^5.1.0 14 - ionicons: ^0.2.2 15 - cupertino_icons: ^1.0.6 16 14 video_player: ^2.10.0 17 15 cached_network_image: ^3.3.1 18 16 camera: ^0.11.1 19 17 path_provider: ^2.1.2 20 - flutter_launcher_icons: ^0.14.3 21 - flutter_svg: ^2.0.7 18 + flutter_svg: ^2.2.0 22 19 atproto: ^0.13.3 23 20 bluesky: ^0.18.10 24 21 http: ^1.2.0 25 22 url_launcher: ^6.2.5 26 - dio: ^5.4.0 27 23 shared_preferences: ^2.5.3 28 24 fvp: ^0.32.1 29 - lucide_icons_flutter: ^3.0.5 30 25 fluentui_system_icons: ^1.1.273 31 26 image_picker: ^1.0.7 32 27 path: ^1.9.1 33 - preload_page_view: ^0.2.0 34 - mime: ^1.0.6 35 28 image: ^4.5.4 36 29 flutter_cache_manager: ^3.3.1 37 - # New architecture dependencies 38 30 synchronized: ^3.1.0 39 - logger: ^2.0.2 40 31 flutter_riverpod: ^2.4.9 41 32 riverpod_annotation: ^2.3.3 42 33 freezed: ^2.4.6 ··· 49 40 sqflite: ^2.4.2 50 41 pool: ^1.5.0 51 42 collection: ^1.19.1 52 - flutter_native_splash: ^2.4.1 53 43 carousel_slider: ^5.0.0 54 44 smooth_video_progress: ^0.0.4 55 45 imgly_editor: ^1.51.0 56 - socket_io_client: ^3.1.2 57 46 web_socket_channel: ^3.0.3 58 47 any_link_preview: ^3.0.3 59 48 ··· 61 50 flutter_test: 62 51 sdk: flutter 63 52 flutter_lints: ^6.0.0 64 - # New architecture dev dependencies 65 - build_runner: ^2.4.8 53 + build_runner: ^2.5.4 66 54 riverpod_generator: ^2.3.9 67 55 json_serializable: ^6.7.1 68 56 auto_route_generator: ^10.2.3 57 + flutter_launcher_icons: ^0.14.3 58 + very_good_analysis: ^9.0.0 69 59 70 60 flutter_launcher_icons: 71 61 android: true ··· 89 79 assets: 90 80 - assets/images/ 91 81 - assets/branding/ 92 - - .env 82 + - .env