[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.

Edit profile actions (#18)

* edit profile integrations

* better colors etc

authored by

Davi Rodrigues and committed by
GitHub
23364515 277f199b

+323 -66
+186
lib/screens/edit_profile_screen.dart
··· 1 + import 'dart:typed_data'; 2 + 3 + import 'package:atproto/core.dart'; 4 + import 'package:cached_network_image/cached_network_image.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:image_picker/image_picker.dart'; 7 + import 'package:provider/provider.dart'; 8 + 9 + import '../models/profile.dart'; 10 + import '../services/auth_service.dart'; 11 + import '../services/profile_service.dart'; 12 + import '../services/sprk_client.dart'; 13 + import '../utils/app_colors.dart'; 14 + import '../utils/app_theme.dart'; 15 + import '../widgets/common/custom_text_field.dart'; 16 + 17 + class EditProfileScreen extends StatefulWidget { 18 + final Profile profile; 19 + 20 + const EditProfileScreen({super.key, required this.profile}); 21 + 22 + @override 23 + State<EditProfileScreen> createState() => _EditProfileScreenState(); 24 + } 25 + 26 + class _EditProfileScreenState extends State<EditProfileScreen> { 27 + late TextEditingController _displayNameController; 28 + late TextEditingController _descriptionController; 29 + dynamic _initialAvatar; 30 + dynamic _localAvatar; 31 + final _formKey = GlobalKey<FormState>(); 32 + bool _isSaving = false; 33 + 34 + @override 35 + void initState() { 36 + super.initState(); 37 + _initialAvatar = widget.profile.avatarUrl; 38 + _localAvatar = _initialAvatar; 39 + _displayNameController = TextEditingController(text: widget.profile.displayName ?? ''); 40 + _descriptionController = TextEditingController(text: widget.profile.description ?? ''); 41 + } 42 + 43 + @override 44 + void dispose() { 45 + _displayNameController.dispose(); 46 + _descriptionController.dispose(); 47 + super.dispose(); 48 + } 49 + 50 + Future<void> _pickAvatar() async { 51 + final picker = ImagePicker(); 52 + final picked = await picker.pickImage(source: ImageSource.gallery); 53 + if (picked == null) return; 54 + final bytes = await picked.readAsBytes(); 55 + if (!mounted) return; 56 + setState(() { 57 + _localAvatar = bytes; 58 + }); 59 + } 60 + 61 + void _revertAvatar() { 62 + setState(() { 63 + _localAvatar = _initialAvatar; 64 + }); 65 + } 66 + 67 + @override 68 + Widget build(BuildContext context) { 69 + final bgColor = AppTheme.getBackgroundColor(context); 70 + ImageProvider<Object>? avatarImageProvider; 71 + if (_localAvatar is Uint8List) { 72 + avatarImageProvider = MemoryImage(_localAvatar as Uint8List); 73 + } else if (_localAvatar is String) { 74 + avatarImageProvider = CachedNetworkImageProvider(_localAvatar as String); 75 + } else { 76 + avatarImageProvider = null; 77 + } 78 + 79 + return Scaffold( 80 + appBar: AppBar(title: const Text('Edit Profile'), backgroundColor: bgColor, elevation: 0), 81 + body: SafeArea( 82 + child: Container( 83 + alignment: Alignment.center, 84 + color: MediaQuery.of(context).platformBrightness == Brightness.light ? Colors.white : bgColor, 85 + child: SingleChildScrollView( 86 + padding: const EdgeInsets.all(16), 87 + child: Form( 88 + key: _formKey, 89 + child: Column( 90 + children: [ 91 + Stack( 92 + alignment: Alignment.topRight, 93 + children: [ 94 + GestureDetector( 95 + onTap: _pickAvatar, 96 + child: CircleAvatar( 97 + radius: 50, 98 + backgroundImage: avatarImageProvider, 99 + child: avatarImageProvider == null ? const Icon(Icons.person, size: 50) : null, 100 + ), 101 + ), 102 + if (_localAvatar is Uint8List) 103 + IconButton(icon: const Icon(Icons.undo), onPressed: _revertAvatar, color: AppColors.pink), 104 + ], 105 + ), 106 + const SizedBox(height: 16), 107 + CustomTextField(controller: _displayNameController, hintText: 'Display Name', fillColor: bgColor), 108 + const SizedBox(height: 16), 109 + CustomTextField(controller: _descriptionController, hintText: 'Description', fillColor: bgColor, maxLines: 3), 110 + const SizedBox(height: 24), 111 + SizedBox( 112 + width: double.infinity, 113 + child: ElevatedButton( 114 + onPressed: _isSaving ? null : _saveProfile, 115 + style: ElevatedButton.styleFrom( 116 + backgroundColor: AppColors.primary, 117 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 118 + ), 119 + child: 120 + _isSaving 121 + ? const SizedBox( 122 + width: 24, 123 + height: 24, 124 + child: CircularProgressIndicator( 125 + valueColor: AlwaysStoppedAnimation<Color>(Colors.white), 126 + strokeWidth: 2, 127 + ), 128 + ) 129 + : const Text( 130 + 'Save', 131 + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), 132 + ), 133 + ), 134 + ), 135 + ], 136 + ), 137 + ), 138 + ), 139 + ), 140 + ), 141 + ); 142 + } 143 + 144 + Future<void> _saveProfile() async { 145 + if (!_formKey.currentState!.validate()) return; 146 + setState(() { 147 + _isSaving = true; 148 + }); 149 + try { 150 + final authService = Provider.of<AuthService>(context, listen: false); 151 + final profileService = Provider.of<ProfileService>(context, listen: false); 152 + final client = SprkClient(authService); 153 + dynamic avatarToSend; 154 + if (_localAvatar is Uint8List) { 155 + // Upload new avatar blob 156 + final respBlob = await client.repo.uploadBlob(_localAvatar as Uint8List); 157 + if (respBlob.status.code != 200) { 158 + throw Exception('Failed to upload avatar blob'); 159 + } 160 + avatarToSend = respBlob.data.blob.toJson(); 161 + } else { 162 + // No new avatar selected: fetch existing record to get blob ref 163 + final uri = AtUri.parse('at://${widget.profile.did}/so.sprk.actor.profile/self'); 164 + final recRes = await client.repo.getRecord(uri: uri); 165 + final recordData = recRes.data.value as Map<String, dynamic>; 166 + avatarToSend = recordData['avatar']; 167 + } 168 + await profileService.updateProfile( 169 + displayName: _displayNameController.text.trim(), 170 + description: _descriptionController.text.trim(), 171 + avatar: avatarToSend, 172 + ); 173 + if (!mounted) return; 174 + Navigator.of(context).pop(true); 175 + } catch (e) { 176 + if (!mounted) return; 177 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating profile: $e'))); 178 + } finally { 179 + if (mounted) { 180 + setState(() { 181 + _isSaving = false; 182 + }); 183 + } 184 + } 185 + } 186 + }
+19 -58
lib/screens/onboarding_screen.dart
··· 7 7 import '../services/auth_service.dart'; 8 8 import '../services/onboarding_service.dart'; 9 9 import '../utils/app_colors.dart'; 10 + import '../widgets/common/custom_text_field.dart'; 10 11 import 'import_follows_screen.dart'; 11 12 12 13 class OnboardingScreen extends StatefulWidget { ··· 173 174 child: Column( 174 175 crossAxisAlignment: CrossAxisAlignment.stretch, 175 176 children: [ 176 - TextFormField( 177 + CustomTextField( 177 178 controller: _displayNameController, 178 - decoration: InputDecoration( 179 - hintText: 'Display Name', 180 - filled: true, 181 - fillColor: backgroundColor, 182 - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 183 - enabledBorder: OutlineInputBorder( 184 - borderRadius: BorderRadius.circular(8), 185 - borderSide: BorderSide(color: AppColors.border), 186 - ), 187 - focusedBorder: OutlineInputBorder( 188 - borderRadius: BorderRadius.circular(8), 189 - borderSide: BorderSide(color: AppColors.pink), 190 - ), 191 - errorBorder: OutlineInputBorder( 192 - borderRadius: BorderRadius.circular(8), 193 - borderSide: BorderSide(color: Colors.red), 194 - ), 195 - focusedErrorBorder: OutlineInputBorder( 196 - borderRadius: BorderRadius.circular(8), 197 - borderSide: BorderSide(color: Colors.red), 198 - ), 199 - suffixIcon: IconButton( 200 - icon: const Icon(Icons.undo, size: 20), 201 - onPressed: () { 202 - if (_bskyProfile != null) { 203 - final value = _bskyProfile!['value'] as Map<String, dynamic>?; 204 - setState(() => _displayNameController.text = value?['displayName'] as String? ?? ''); 205 - } 206 - }, 207 - tooltip: 'Revert display name', 208 - ), 209 - ), 179 + hintText: 'Display Name', 180 + fillColor: backgroundColor, 181 + onUndo: () { 182 + if (_bskyProfile != null) { 183 + final value = _bskyProfile!['value'] as Map<String, dynamic>?; 184 + setState(() => _displayNameController.text = value?['displayName'] as String? ?? ''); 185 + } 186 + }, 210 187 validator: (value) { 211 188 if (value == null || value.trim().isEmpty) return 'Display Name is required'; 212 189 return null; 213 190 }, 214 191 ), 215 192 const SizedBox(height: 12), 216 - TextFormField( 193 + CustomTextField( 217 194 controller: _descriptionController, 218 - decoration: InputDecoration( 219 - hintText: 'Bio', 220 - filled: true, 221 - fillColor: backgroundColor, 222 - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 223 - enabledBorder: OutlineInputBorder( 224 - borderRadius: BorderRadius.circular(8), 225 - borderSide: BorderSide(color: AppColors.border), 226 - ), 227 - focusedBorder: OutlineInputBorder( 228 - borderRadius: BorderRadius.circular(8), 229 - borderSide: BorderSide(color: AppColors.pink), 230 - ), 231 - suffixIcon: IconButton( 232 - icon: const Icon(Icons.undo, size: 20), 233 - onPressed: () { 234 - if (_bskyProfile != null) { 235 - final value = _bskyProfile!['value'] as Map<String, dynamic>?; 236 - setState(() => _descriptionController.text = value?['description'] as String? ?? ''); 237 - } 238 - }, 239 - tooltip: 'Revert bio', 240 - ), 241 - ), 195 + hintText: 'Bio', 196 + fillColor: backgroundColor, 242 197 maxLines: 3, 198 + onUndo: () { 199 + if (_bskyProfile != null) { 200 + final value = _bskyProfile!['value'] as Map<String, dynamic>?; 201 + setState(() => _descriptionController.text = value?['description'] as String? ?? ''); 202 + } 203 + }, 243 204 ), 244 205 const SizedBox(height: 24), 245 206 Align(
+18 -4
lib/screens/profile_screen.dart
··· 1 1 import 'dart:convert'; 2 2 3 + import 'package:atproto/atproto.dart'; 3 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:http/http.dart' as http; 6 7 import 'package:provider/provider.dart'; 7 - import 'package:atproto/atproto.dart'; 8 + import 'package:sparksocial/services/mod_service.dart'; 8 9 import 'package:sparksocial/widgets/action_buttons/menu_action_button.dart'; 9 10 import 'package:sparksocial/widgets/dialogs/report_dialog.dart'; 10 - import 'package:sparksocial/services/mod_service.dart'; 11 11 12 12 import '../models/profile.dart'; 13 + import '../services/actions_service.dart'; 13 14 import '../services/auth_service.dart'; 14 15 import '../services/profile_service.dart'; 15 16 import '../utils/app_colors.dart'; ··· 20 21 import '../widgets/profile/profile_tab_content.dart'; 21 22 import '../widgets/profile/profile_tabs.dart'; 22 23 import 'auth_prompt_screen.dart'; 23 - import '../services/actions_service.dart'; 24 + import 'edit_profile_screen.dart'; 24 25 25 26 class ProfileScreen extends StatefulWidget { 26 27 final String? did; ··· 188 189 } 189 190 } 190 191 192 + /// Navigate to EditProfileScreen and refresh profile on update 193 + void _navigateToEdit() { 194 + if (_profile == null) return; 195 + Navigator.of(context).push<bool>(MaterialPageRoute(builder: (context) => EditProfileScreen(profile: _profile!))).then(( 196 + updated, 197 + ) { 198 + if (updated == true && mounted) { 199 + _loadProfile(); 200 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Profile updated successfully'))); 201 + } 202 + }); 203 + } 204 + 191 205 bool _isCurrentUser() { 192 206 if (_profile == null) return false; 193 207 final authService = Provider.of<AuthService>(context, listen: false); ··· 354 368 isCurrentUser: isCurrentUser, 355 369 isEarlySupporter: _isEarlySupporter, 356 370 onEarlySupporterTap: () => _showEarlySupporterInfo(context), 357 - onEditTap: () => _checkAuthAndProceed(() => debugPrint('Edit profile tapped')), 371 + onEditTap: () => _checkAuthAndProceed(_navigateToEdit), 358 372 onShareTap: () => debugPrint('Share profile tapped'), 359 373 onFollowTap: () => _checkAuthAndProceed(_handleFollow), 360 374 onSettingsTap: _handleSettingsTap,
+22
lib/services/profile_service.dart
··· 223 223 throw Exception('Failed to fetch profile: $e'); 224 224 } 225 225 } 226 + 227 + /// Updates existing Spark actor profile 228 + Future<void> updateProfile({required String displayName, required String description, dynamic avatar}) async { 229 + if (!_authService.isAuthenticated) { 230 + throw Exception('Not authenticated'); 231 + } 232 + final client = SprkClient(_authService); 233 + final record = <String, dynamic>{ 234 + '\$type': 'so.sprk.actor.profile', 235 + 'displayName': displayName, 236 + 'description': description, 237 + if (avatar != null) 'avatar': avatar, 238 + }; 239 + final response = await client.repo.editRecord( 240 + uri: AtUri.parse('at://${_authService.session!.did}/so.sprk.actor.profile/self'), 241 + record: record, 242 + ); 243 + if (response.status.code != 200) { 244 + throw Exception('Failed to update Spark profile: \\${response.status.code} \\${response.data}'); 245 + } 246 + notifyListeners(); 247 + } 226 248 }
+17
lib/services/sprk_client.dart
··· 264 264 }); 265 265 } 266 266 267 + /// Edit a record in the repository 268 + /// 269 + /// [uri] The URI of the record to edit 270 + /// [record] The record data to edit 271 + Future<dynamic> editRecord({required AtUri uri, required Map<String, dynamic> record}) async { 272 + return _client._executeWithRetry(() async { 273 + if (!_client._authService.isAuthenticated) { 274 + throw Exception('Not authenticated'); 275 + } 276 + final atproto = _client._authService.atproto; 277 + if (atproto == null) { 278 + throw Exception('AtProto not initialized'); 279 + } 280 + return await atproto.repo.putRecord(uri: uri, record: record); 281 + }); 282 + } 283 + 267 284 /// Create a record in the repository 268 285 /// 269 286 /// [collection] The NSID of the collection to create the record in
+47
lib/widgets/common/custom_text_field.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../../utils/app_colors.dart'; 4 + 5 + /// A custom styled text field with optional undo functionality. 6 + class CustomTextField extends StatelessWidget { 7 + final TextEditingController controller; 8 + final String hintText; 9 + final Color fillColor; 10 + final int maxLines; 11 + final VoidCallback? onUndo; 12 + final String? Function(String?)? validator; 13 + 14 + const CustomTextField({ 15 + super.key, 16 + required this.controller, 17 + required this.hintText, 18 + required this.fillColor, 19 + this.maxLines = 1, 20 + this.onUndo, 21 + this.validator, 22 + }); 23 + 24 + @override 25 + Widget build(BuildContext context) { 26 + return TextFormField( 27 + controller: controller, 28 + maxLines: maxLines, 29 + validator: validator, 30 + decoration: InputDecoration( 31 + hintText: hintText, 32 + filled: true, 33 + fillColor: fillColor, 34 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 35 + enabledBorder: OutlineInputBorder( 36 + borderRadius: BorderRadius.circular(8), 37 + borderSide: BorderSide(color: AppColors.border), 38 + ), 39 + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: AppColors.pink)), 40 + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.red)), 41 + focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.red)), 42 + suffixIcon: 43 + onUndo != null ? IconButton(icon: const Icon(Icons.undo, size: 20), onPressed: onUndo, tooltip: 'Revert') : null, 44 + ), 45 + ); 46 + } 47 + }
+14 -4
lib/widgets/profile/profile_header.dart
··· 211 211 Row( 212 212 children: [ 213 213 if (widget.isCurrentUser) ...[ 214 - // Expanded( 215 - // flex: 1, 216 - // child: ProfileActionButton(label: 'Edit', onPressed: widget.onEditTap, isPrimary: true, isOutlined: false), 217 - // ), 214 + Expanded( 215 + flex: 1, 216 + child: Container( 217 + constraints: const BoxConstraints(minHeight: 36), 218 + child: ElevatedButton( 219 + onPressed: widget.onEditTap, 220 + style: ElevatedButton.styleFrom( 221 + backgroundColor: AppColors.primary, 222 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), 223 + ), 224 + child: Text('Edit Profile', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.white)), 225 + ), 226 + ), 227 + ), 218 228 const SizedBox(width: 8), 219 229 ] else ...[ 220 230 Expanded(