user_profile_service.dart
1 import 'dart:convert'; 2 import 'dart:io'; 3 4 import 'package:path_provider/path_provider.dart'; 5 import 'package:shared_preferences/shared_preferences.dart'; 6 7 /// Service for managing the user's own profile (picture, display name, sharing preferences). 8 /// Singleton backed by SharedPreferences + file on disk. 9 /// Picture paths are stored RELATIVE to the documents directory to survive 10 /// iOS container path changes across app restarts. 11 class UserProfileService { 12 static UserProfileService? _instance; 13 static const _prefsKey = 'user_profile'; 14 15 SharedPreferences? _prefs; 16 String? _picturePath; // Relative path (e.g. "user_profile/avatar.png") 17 String? _displayName; 18 Set<String> _shareProfileContactIds = {}; 19 bool _shareWithAll = false; 20 21 UserProfileService._(); 22 23 static UserProfileService get instance { 24 _instance ??= UserProfileService._(); 25 return _instance!; 26 } 27 28 /// Returns the resolved absolute path, or null if no picture or file missing. 29 Future<String?> get picturePath async { 30 if (_picturePath == null) return null; 31 return _resolveAbsolutePath(_picturePath!); 32 } 33 34 String? get displayName => _displayName; 35 Set<String> get shareProfileContactIds => Set.unmodifiable(_shareProfileContactIds); 36 bool get shareWithAll => _shareWithAll; 37 38 Future<void> _ensureInitialized() async { 39 _prefs ??= await SharedPreferences.getInstance(); 40 _loadFromStorage(); 41 } 42 43 void _loadFromStorage() { 44 final stored = _prefs?.getString(_prefsKey); 45 if (stored != null) { 46 try { 47 final data = json.decode(stored) as Map<String, dynamic>; 48 _picturePath = data['picturePath'] as String?; 49 _displayName = data['displayName'] as String?; 50 final ids = data['shareProfileContactIds'] as List<dynamic>?; 51 _shareProfileContactIds = ids?.map((e) => e as String).toSet() ?? {}; 52 _shareWithAll = data['shareWithAll'] as bool? ?? false; 53 } catch (_) { 54 // Ignore corrupt data 55 } 56 } 57 } 58 59 Future<void> _saveToStorage() async { 60 final data = { 61 'picturePath': _picturePath, 62 'displayName': _displayName, 63 'shareProfileContactIds': _shareProfileContactIds.toList(), 64 'shareWithAll': _shareWithAll, 65 }; 66 await _prefs?.setString(_prefsKey, json.encode(data)); 67 } 68 69 /// Resolve a relative (or legacy absolute) path to an absolute path. 70 Future<String?> _resolveAbsolutePath(String path) async { 71 // If already absolute (legacy data), check if it exists 72 if (path.startsWith('/')) { 73 if (await File(path).exists()) return path; 74 // Try to extract relative part from legacy absolute path 75 final idx = path.indexOf('user_profile/'); 76 if (idx >= 0) { 77 final relPart = path.substring(idx); 78 final appDir = await getApplicationDocumentsDirectory(); 79 final absPath = '${appDir.path}/$relPart'; 80 if (await File(absPath).exists()) return absPath; 81 } 82 return null; 83 } 84 final appDir = await getApplicationDocumentsDirectory(); 85 final absPath = '${appDir.path}/$path'; 86 if (await File(absPath).exists()) return absPath; 87 return null; 88 } 89 90 /// Initialize the service (call once at app startup). 91 Future<void> init() async { 92 await _ensureInitialized(); 93 } 94 95 /// Set the user's profile picture from a file. 96 /// Returns the stored absolute path. 97 Future<String> setPicture(File imageFile) async { 98 await _ensureInitialized(); 99 100 final appDir = await getApplicationDocumentsDirectory(); 101 final profileDir = Directory('${appDir.path}/user_profile'); 102 if (!await profileDir.exists()) { 103 await profileDir.create(recursive: true); 104 } 105 106 // Delete old picture if it exists 107 if (_picturePath != null) { 108 try { 109 final oldAbsPath = await _resolveAbsolutePath(_picturePath!); 110 if (oldAbsPath != null) await File(oldAbsPath).delete(); 111 } catch (_) {} 112 } 113 114 final extension = imageFile.path.split('.').last; 115 final timestamp = DateTime.now().millisecondsSinceEpoch; 116 final relativePath = 'user_profile/avatar_$timestamp.$extension'; 117 final absolutePath = '${appDir.path}/$relativePath'; 118 await imageFile.copy(absolutePath); 119 120 _picturePath = relativePath; 121 await _saveToStorage(); 122 return absolutePath; 123 } 124 125 /// Remove the user's profile picture. 126 Future<void> removePicture() async { 127 await _ensureInitialized(); 128 129 if (_picturePath != null) { 130 try { 131 final absPath = await _resolveAbsolutePath(_picturePath!); 132 if (absPath != null) await File(absPath).delete(); 133 } catch (_) {} 134 _picturePath = null; 135 await _saveToStorage(); 136 } 137 } 138 139 /// Set the user's display name. 140 Future<void> setDisplayName(String? name) async { 141 await _ensureInitialized(); 142 _displayName = (name != null && name.isEmpty) ? null : name; 143 await _saveToStorage(); 144 } 145 146 /// Check if profile is shared with a specific contact. 147 bool isSharedWith(String contactIdHex) { 148 return _shareProfileContactIds.contains(contactIdHex); 149 } 150 151 /// Toggle sharing with a specific contact. Returns the new sharing state. 152 Future<bool> toggleShareWith(String contactIdHex) async { 153 await _ensureInitialized(); 154 final nowShared = !_shareProfileContactIds.contains(contactIdHex); 155 if (nowShared) { 156 _shareProfileContactIds.add(contactIdHex); 157 } else { 158 _shareProfileContactIds.remove(contactIdHex); 159 } 160 await _saveToStorage(); 161 return nowShared; 162 } 163 164 /// Explicitly set sharing state for a contact. 165 Future<void> setShareWith(String contactIdHex, bool share) async { 166 await _ensureInitialized(); 167 if (share) { 168 _shareProfileContactIds.add(contactIdHex); 169 } else { 170 _shareProfileContactIds.remove(contactIdHex); 171 } 172 await _saveToStorage(); 173 } 174 175 /// Set the global share-with-all flag. 176 Future<void> setShareWithAll(bool value) async { 177 await _ensureInitialized(); 178 _shareWithAll = value; 179 await _saveToStorage(); 180 } 181 182 /// Ensure all given contact IDs are in the shared set. 183 /// Returns the list of hex IDs that were newly added. 184 Future<List<String>> ensureAllContactsShared(List<String> allContactHexIds) async { 185 await _ensureInitialized(); 186 final newlyAdded = <String>[]; 187 for (final hexId in allContactHexIds) { 188 if (!_shareProfileContactIds.contains(hexId)) { 189 _shareProfileContactIds.add(hexId); 190 newlyAdded.add(hexId); 191 } 192 } 193 if (newlyAdded.isNotEmpty) { 194 await _saveToStorage(); 195 } 196 return newlyAdded; 197 } 198 199 /// Get the profile picture as bytes (for sending to contacts). 200 Future<List<int>?> getPictureBytes() async { 201 if (_picturePath == null) return null; 202 try { 203 final absPath = await _resolveAbsolutePath(_picturePath!); 204 if (absPath == null) return null; 205 return await File(absPath).readAsBytes(); 206 } catch (_) { 207 return null; 208 } 209 } 210 }