From 61fbc44284366600638cd85f80ab958c82cecde4 Mon Sep 17 00:00:00 2001 From: Bretton Date: Fri, 23 Jan 2026 00:26:44 -0800 Subject: [PATCH] feat(admin): add community profile picture management Extract admin functionality into dedicated CommunitiesAdminPanel with a menu-based navigation system. Add ability for admins to update community avatars via the new social.coves.community.update XRPC endpoint. Changes: - Add CommunitiesAdminPanel with multi-page navigation (menu, create, profile pic) - Add updateCommunity API method with base64 image encoding - Add image_picker dependency for gallery/camera image selection - Simplify CommunitiesScreen to StatelessWidget, delegating admin to panel - Validate image size (max 1 MB) and MIME type (jpeg/png/webp) - Upgrade Gradle to 8.11.1 and Android plugin to 8.9.1 - Add current vs new avatar preview before upload Co-Authored-By: Claude Opus 4.5 --- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 2 +- lib/screens/home/communities_admin_panel.dart | 1281 +++++++++++++++++ lib/screens/home/communities_screen.dart | 436 +----- lib/services/coves_api_service.dart | 84 ++ pubspec.lock | 104 ++ pubspec.yaml | 1 + 7 files changed, 1488 insertions(+), 422 deletions(-) create mode 100644 lib/screens/home/communities_admin_panel.dart diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index afa1e8e..efdcc4a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 11662c3..43394ed 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.0" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/lib/screens/home/communities_admin_panel.dart b/lib/screens/home/communities_admin_panel.dart new file mode 100644 index 0000000..37b95c8 --- /dev/null +++ b/lib/screens/home/communities_admin_panel.dart @@ -0,0 +1,1281 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; + +import '../../constants/app_colors.dart'; +import '../../models/community.dart'; +import '../../providers/auth_provider.dart'; +import '../../services/api_exceptions.dart'; +import '../../services/coves_api_service.dart'; + +/// Admin handles that can create communities +const Set kAdminHandles = { + 'coves.social', + 'alex.local.coves.dev', // Local development account + 'mari.local.coves.dev', // Local development account +}; + +/// Regex for DNS-valid community names (lowercase alphanumeric and hyphens) +final RegExp _dnsNameRegex = RegExp(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$'); + +/// Admin panel pages +enum AdminPage { + menu, + createCommunity, + changeProfilePic, +} + +/// Admin Panel for Communities +/// +/// Provides admin-only functionality: +/// - Community creation form +/// - Profile picture management +class CommunitiesAdminPanel extends StatefulWidget { + const CommunitiesAdminPanel({super.key}); + + @override + State createState() => _CommunitiesAdminPanelState(); +} + +class _CommunitiesAdminPanelState extends State { + // Current admin page + AdminPage _currentPage = AdminPage.menu; + + // Form controllers for create community + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _displayNameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + // Form controllers for change profile pic + final TextEditingController _communityHandleController = + TextEditingController(); + + // API service (cached to avoid repeated instantiation) + CovesApiService? _apiService; + + // Form state + bool _isSubmitting = false; + String? _nameError; + List _createdCommunities = []; + + // Profile pic state + bool _isLoadingCommunities = false; + List _communities = []; + CommunityView? _selectedCommunity; + File? _selectedImage; + final ImagePicker _imagePicker = ImagePicker(); + + // Computed state + bool get _isFormValid { + return _nameController.text.trim().isNotEmpty && + _displayNameController.text.trim().isNotEmpty && + _descriptionController.text.trim().isNotEmpty; + } + + // Generate handle preview from name + String get _handlePreview { + final name = _nameController.text.trim().toLowerCase(); + if (name.isEmpty) return '@c-{name}.coves.social'; + return '@c-$name.coves.social'; + } + + @override + void initState() { + super.initState(); + _nameController.addListener(_onTextChanged); + _displayNameController.addListener(_onTextChanged); + _descriptionController.addListener(_onTextChanged); + } + + @override + void dispose() { + // Remove listeners before disposing controllers + _nameController.removeListener(_onTextChanged); + _displayNameController.removeListener(_onTextChanged); + _descriptionController.removeListener(_onTextChanged); + _nameController.dispose(); + _displayNameController.dispose(); + _descriptionController.dispose(); + _communityHandleController.dispose(); + _apiService?.dispose(); + super.dispose(); + } + + void _onTextChanged() { + // Clear name error when user types + if (_nameError != null) { + setState(() { + _nameError = null; + }); + } else { + setState(() {}); + } + } + + /// Validates the community name is DNS-valid + bool _validateName() { + final name = _nameController.text.trim().toLowerCase(); + + if (name.isEmpty) { + setState(() => _nameError = 'Name is required'); + return false; + } + + if (name.length > 63) { + setState(() => _nameError = 'Name must be 63 characters or less'); + return false; + } + + if (!_dnsNameRegex.hasMatch(name)) { + setState(() { + _nameError = + 'Name must be lowercase letters, numbers, and hyphens only'; + }); + return false; + } + + setState(() => _nameError = null); + return true; + } + + /// Gets or creates the cached API service + CovesApiService _getApiService() { + if (_apiService == null) { + final authProvider = context.read(); + _apiService = CovesApiService( + tokenGetter: authProvider.getAccessToken, + tokenRefresher: authProvider.refreshToken, + signOutHandler: authProvider.signOut, + ); + } + return _apiService!; + } + + Future _createCommunity() async { + if (!_isFormValid || _isSubmitting) return; + + // Validate DNS-valid name before API call + if (!_validateName()) return; + + setState(() { + _isSubmitting = true; + }); + + try { + final apiService = _getApiService(); + + final response = await apiService.createCommunity( + name: _nameController.text.trim().toLowerCase(), + displayName: _displayNameController.text.trim(), + description: _descriptionController.text.trim(), + ); + + if (mounted) { + setState(() { + _createdCommunities = [..._createdCommunities, response]; + _isSubmitting = false; + }); + + // Clear form + _nameController.clear(); + _displayNameController.clear(); + _descriptionController.clear(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Community created: ${response.handle}'), + backgroundColor: Colors.green[700], + behavior: SnackBarBehavior.floating, + ), + ); + } + } on ApiException catch (e) { + if (kDebugMode) { + debugPrint('API error creating community: ${e.message}'); + } + if (mounted) { + setState(() { + _isSubmitting = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create community: ${e.message}'), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e, stackTrace) { + if (kDebugMode) { + debugPrint('Unexpected error in _createCommunity: $e'); + debugPrint('Stack trace: $stackTrace'); + } + if (mounted) { + setState(() { + _isSubmitting = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('An unexpected error occurred. Please try again.'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + String _getAdminTitle() { + switch (_currentPage) { + case AdminPage.menu: + return 'Admin: Communities'; + case AdminPage.createCommunity: + return 'Create Community'; + case AdminPage.changeProfilePic: + return 'Change Profile Pic'; + } + } + + void _navigateToPage(AdminPage page) { + setState(() { + _currentPage = page; + }); + // Load communities when navigating to profile pic page + if (page == AdminPage.changeProfilePic) { + _loadCommunities(); + } + } + + void _navigateBack() { + setState(() { + _currentPage = AdminPage.menu; + _selectedCommunity = null; + _selectedImage = null; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + foregroundColor: Colors.white, + title: Text(_getAdminTitle()), + automaticallyImplyLeading: false, + leading: _currentPage != AdminPage.menu + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _navigateBack, + ) + : null, + ), + body: _buildAdminUI(), + ); + } + + Future _loadCommunities() async { + if (_isLoadingCommunities) return; + + setState(() { + _isLoadingCommunities = true; + }); + + try { + final apiService = _getApiService(); + final response = await apiService.listCommunities(); + + if (mounted) { + if (kDebugMode) { + for (final c in response.communities) { + debugPrint('Community: ${c.name}, avatar: ${c.avatar}'); + } + } + setState(() { + _communities = response.communities; + _isLoadingCommunities = false; + }); + } + } catch (e) { + if (kDebugMode) { + debugPrint('Error loading communities: $e'); + } + if (mounted) { + setState(() { + _isLoadingCommunities = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to load communities'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + Widget _buildAdminUI() { + switch (_currentPage) { + case AdminPage.menu: + return _buildAdminMenu(); + case AdminPage.createCommunity: + return _buildCreateCommunityUI(); + case AdminPage.changeProfilePic: + return _buildChangeProfilePicUI(); + } + } + + Widget _buildAdminMenu() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Admin Tools', + style: TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Manage communities and settings', + style: TextStyle(fontSize: 14, color: Color(0xFFB6C2D2)), + ), + const SizedBox(height: 24), + _buildAdminMenuItem( + icon: Icons.add_circle_outline, + title: 'Create Community', + subtitle: 'Create a new community for Coves users', + onTap: () => _navigateToPage(AdminPage.createCommunity), + ), + const SizedBox(height: 12), + _buildAdminMenuItem( + icon: Icons.image_outlined, + title: 'Change Profile Pic', + subtitle: 'Update a community\'s profile picture', + onTap: () => _navigateToPage(AdminPage.changeProfilePic), + ), + ], + ), + ); + } + + Widget _buildAdminMenuItem({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColors.primary, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + color: Color(0xFFB6C2D2), + fontSize: 14, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFFB6C2D2), + ), + ], + ), + ), + ); + } + + Widget _buildCreateCommunityUI() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + const Text( + 'Create Community', + style: TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Create a new community for Coves users', + style: TextStyle(fontSize: 14, color: Color(0xFFB6C2D2)), + ), + const SizedBox(height: 24), + + // Name field (DNS-valid slug) + _buildTextField( + controller: _nameController, + label: 'Name (unique identifier)', + hint: 'worldnews', + helperText: 'DNS-valid, lowercase, no spaces', + errorText: _nameError, + ), + const SizedBox(height: 16), + + // Handle preview + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + const Icon(Icons.link, color: AppColors.primary, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _handlePreview, + style: const TextStyle( + color: AppColors.primary, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Display Name field + _buildTextField( + controller: _displayNameController, + label: 'Display Name', + hint: 'World News', + helperText: 'Human-readable name shown in the UI', + ), + const SizedBox(height: 16), + + // Description field + _buildTextField( + controller: _descriptionController, + label: 'Description', + hint: 'Global news and current events from around the world', + maxLines: 3, + ), + const SizedBox(height: 24), + + // Create button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + _isFormValid && !_isSubmitting ? _createCommunity : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + disabledBackgroundColor: AppColors.backgroundSecondary, + ), + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + 'Create Community', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + + // Created communities list + if (_createdCommunities.isNotEmpty) ...[ + const SizedBox(height: 32), + const Text( + 'Created Communities', + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ..._createdCommunities + .map((community) => _buildCommunityTile(community)), + ], + ], + ), + ); + } + + Widget _buildChangeProfilePicUI() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Change Profile Picture', + style: TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Select a community and upload a new profile picture', + style: TextStyle(fontSize: 14, color: Color(0xFFB6C2D2)), + ), + const SizedBox(height: 24), + + // Community selector + const Text( + 'Select Community', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + + if (_isLoadingCommunities) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ), + ) + else if (_communities.isEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Center( + child: Text( + 'No communities found', + style: TextStyle(color: Color(0xFFB6C2D2)), + ), + ), + ) + else + ...(_communities.map((community) => _buildCommunitySelectTile(community))), + + if (_selectedCommunity != null) ...[ + const SizedBox(height: 24), + + // Show current vs new image comparison when image is selected + if (_selectedImage != null) ...[ + const Text( + 'Preview Changes', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Current image + Column( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(50), + border: Border.all(color: AppColors.border, width: 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(50), + child: _selectedCommunity!.avatar != null + ? Image.network( + _selectedCommunity!.avatar!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon( + Icons.workspaces_outlined, + size: 40, + color: AppColors.primary, + ), + ) + : const Icon( + Icons.workspaces_outlined, + size: 40, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 8), + const Text( + 'Current', + style: TextStyle( + color: Color(0xFFB6C2D2), + fontSize: 12, + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Icon( + Icons.arrow_forward, + color: AppColors.primary, + size: 24, + ), + ), + // New image preview + Column( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(50), + border: Border.all(color: AppColors.primary, width: 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(50), + child: Image.file( + _selectedImage!, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 8), + const Text( + 'New', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Center( + child: Text( + _selectedCommunity!.displayName ?? _selectedCommunity!.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + Center( + child: Text( + '@${_selectedCommunity!.handle ?? _selectedCommunity!.name}', + style: const TextStyle( + color: Color(0xFFB6C2D2), + fontSize: 14, + ), + ), + ), + const SizedBox(height: 24), + + // Action buttons when image is selected + Row( + children: [ + // Clear button + Expanded( + child: OutlinedButton.icon( + onPressed: _clearSelectedImage, + icon: const Icon(Icons.close), + label: const Text('Clear'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: AppColors.border), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + // Upload button + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _isSubmitting ? null : _uploadImage, + icon: const Icon(Icons.upload), + label: Text(_isSubmitting ? 'Uploading...' : 'Upload'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + disabledBackgroundColor: AppColors.backgroundSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Select different image button + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: _pickAndUploadImage, + icon: const Icon(Icons.photo_library, size: 18), + label: const Text('Select Different Image'), + style: TextButton.styleFrom( + foregroundColor: AppColors.teal, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ] else ...[ + // Show current profile picture when no new image is selected + const Text( + 'Current Profile Picture', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Center( + child: Column( + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(60), + border: Border.all(color: AppColors.border, width: 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(60), + child: _selectedCommunity!.avatar != null + ? Image.network( + _selectedCommunity!.avatar!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon( + Icons.workspaces_outlined, + size: 48, + color: AppColors.primary, + ), + ) + : const Icon( + Icons.workspaces_outlined, + size: 48, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 8), + Text( + _selectedCommunity!.displayName ?? _selectedCommunity!.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + Text( + '@${_selectedCommunity!.handle ?? _selectedCommunity!.name}', + style: const TextStyle( + color: Color(0xFFB6C2D2), + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Select image button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isSubmitting ? null : _pickAndUploadImage, + icon: const Icon(Icons.add_photo_alternate), + label: const Text('Select New Picture'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + disabledBackgroundColor: AppColors.backgroundSecondary, + ), + ), + ), + ], + ], + ], + ), + ); + } + + Widget _buildCommunitySelectTile(CommunityView community) { + final isSelected = _selectedCommunity?.did == community.did; + return GestureDetector( + onTap: () { + setState(() { + _selectedCommunity = community; + }); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? AppColors.primary : AppColors.border, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: community.avatar != null + ? Image.network( + community.avatar!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon( + Icons.workspaces_outlined, + size: 20, + color: AppColors.primary, + ), + ) + : const Icon( + Icons.workspaces_outlined, + size: 20, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + community.displayName ?? community.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + Text( + '@${community.handle ?? community.name}', + style: const TextStyle( + color: Color(0xFFB6C2D2), + fontSize: 12, + ), + ), + ], + ), + ), + if (isSelected) + const Icon( + Icons.check_circle, + color: AppColors.primary, + size: 24, + ), + ], + ), + ), + ); + } + + Future _pickAndUploadImage() async { + // Show bottom sheet to choose between gallery and camera + final source = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.backgroundSecondary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + const Text( + 'Select Image Source', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ListTile( + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.photo_library, + color: AppColors.primary, + ), + ), + title: const Text( + 'Choose from Gallery', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Select an existing photo', + style: TextStyle(color: Color(0xFFB6C2D2), fontSize: 12), + ), + onTap: () => Navigator.pop(context, ImageSource.gallery), + ), + ListTile( + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.teal.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.camera_alt, + color: AppColors.teal, + ), + ), + title: const Text( + 'Take a Photo', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Use camera to capture', + style: TextStyle(color: Color(0xFFB6C2D2), fontSize: 12), + ), + onTap: () => Navigator.pop(context, ImageSource.camera), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + + if (source == null) { + return; + } + + try { + final pickedFile = await _imagePicker.pickImage( + source: source, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); + + if (pickedFile != null && mounted) { + setState(() { + _selectedImage = File(pickedFile.path); + }); + } + } on Exception catch (e) { + if (kDebugMode) { + debugPrint('Error picking image: $e'); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to pick image: ${e.toString()}'), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + Future _uploadImage() async { + if (_selectedImage == null || _selectedCommunity == null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + // Read the image file as bytes + final imageBytes = await _selectedImage!.readAsBytes(); + + // Determine MIME type from file extension + final extension = _selectedImage!.path.split('.').last.toLowerCase(); + String mimeType; + switch (extension) { + case 'jpg': + case 'jpeg': + mimeType = 'image/jpeg'; + case 'png': + mimeType = 'image/png'; + case 'webp': + mimeType = 'image/webp'; + default: + // Default to JPEG for unknown extensions + mimeType = 'image/jpeg'; + } + + if (kDebugMode) { + debugPrint( + 'Uploading image: ${imageBytes.length} bytes, $mimeType', + ); + } + + final apiService = _getApiService(); + + await apiService.updateCommunity( + communityDid: _selectedCommunity!.did, + imageBytes: imageBytes, + mimeType: mimeType, + ); + + if (mounted) { + setState(() { + _isSubmitting = false; + _selectedImage = null; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Avatar updated for ${_selectedCommunity!.displayName ?? _selectedCommunity!.name}', + ), + backgroundColor: Colors.green[700], + behavior: SnackBarBehavior.floating, + ), + ); + + // Reload communities list to show updated avatar + await _loadCommunities(); + } + } on ApiException catch (e) { + if (kDebugMode) { + debugPrint('API error uploading avatar: ${e.message}'); + } + if (mounted) { + setState(() { + _isSubmitting = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to upload avatar: ${e.message}'), + backgroundColor: Colors.red[700], + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e, stackTrace) { + if (kDebugMode) { + debugPrint('Unexpected error in _uploadImage: $e'); + debugPrint('Stack trace: $stackTrace'); + } + if (mounted) { + setState(() { + _isSubmitting = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('An unexpected error occurred. Please try again.'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + void _clearSelectedImage() { + setState(() { + _selectedImage = null; + }); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required String hint, + String? helperText, + String? errorText, + int maxLines = 1, + }) { + final hasError = errorText != null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + maxLines: maxLines, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.4)), + helperText: hasError ? null : helperText, + helperStyle: const TextStyle(color: Color(0xFFB6C2D2)), + errorText: errorText, + errorStyle: const TextStyle(color: Colors.red), + filled: true, + fillColor: AppColors.backgroundSecondary, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: hasError ? Colors.red : AppColors.border, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: hasError ? Colors.red : AppColors.primary, + ), + ), + ), + ), + ], + ); + } + + Widget _buildCommunityTile(CreateCommunityResponse community) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + community.handle, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + Text( + community.did, + style: const TextStyle( + color: Color(0xFFB6C2D2), + fontSize: 12, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home/communities_screen.dart b/lib/screens/home/communities_screen.dart index 1c7b243..0d1833c 100644 --- a/lib/screens/home/communities_screen.dart +++ b/lib/screens/home/communities_screen.dart @@ -3,205 +3,17 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../constants/app_colors.dart'; -import '../../models/community.dart'; import '../../providers/auth_provider.dart'; -import '../../services/api_exceptions.dart'; -import '../../services/coves_api_service.dart'; - -/// Admin handles that can create communities -const Set kAdminHandles = { - 'coves.social', - 'alex.local.coves.dev', // Local development account -}; - -/// Regex for DNS-valid community names (lowercase alphanumeric and hyphens) -final RegExp _dnsNameRegex = RegExp(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$'); +import 'communities_admin_panel.dart'; /// Communities Screen /// /// Shows different UI based on user role: -/// - Admin (coves.social): Community creation form +/// - Admin (kAdminHandles): Full admin panel for community management /// - Regular users: Placeholder with coming soon message -class CommunitiesScreen extends StatefulWidget { +class CommunitiesScreen extends StatelessWidget { const CommunitiesScreen({super.key}); - @override - State createState() => _CommunitiesScreenState(); -} - -class _CommunitiesScreenState extends State { - // Form controllers - final TextEditingController _nameController = TextEditingController(); - final TextEditingController _displayNameController = TextEditingController(); - final TextEditingController _descriptionController = TextEditingController(); - - // API service (cached to avoid repeated instantiation) - CovesApiService? _apiService; - - // Form state - bool _isSubmitting = false; - String? _nameError; - List _createdCommunities = []; - - // Computed state - bool get _isFormValid { - return _nameController.text.trim().isNotEmpty && - _displayNameController.text.trim().isNotEmpty && - _descriptionController.text.trim().isNotEmpty; - } - - // Generate handle preview from name - String get _handlePreview { - final name = _nameController.text.trim().toLowerCase(); - if (name.isEmpty) return '@c-{name}.coves.social'; - return '@c-$name.coves.social'; - } - - @override - void initState() { - super.initState(); - _nameController.addListener(_onTextChanged); - _displayNameController.addListener(_onTextChanged); - _descriptionController.addListener(_onTextChanged); - } - - @override - void dispose() { - // Remove listeners before disposing controllers - _nameController.removeListener(_onTextChanged); - _displayNameController.removeListener(_onTextChanged); - _descriptionController.removeListener(_onTextChanged); - _nameController.dispose(); - _displayNameController.dispose(); - _descriptionController.dispose(); - _apiService?.dispose(); - super.dispose(); - } - - void _onTextChanged() { - // Clear name error when user types - if (_nameError != null) { - setState(() { - _nameError = null; - }); - } else { - setState(() {}); - } - } - - /// Validates the community name is DNS-valid - bool _validateName() { - final name = _nameController.text.trim().toLowerCase(); - - if (name.isEmpty) { - setState(() => _nameError = 'Name is required'); - return false; - } - - if (name.length > 63) { - setState(() => _nameError = 'Name must be 63 characters or less'); - return false; - } - - if (!_dnsNameRegex.hasMatch(name)) { - setState(() { - _nameError = - 'Name must be lowercase letters, numbers, and hyphens only'; - }); - return false; - } - - setState(() => _nameError = null); - return true; - } - - /// Gets or creates the cached API service - CovesApiService _getApiService() { - if (_apiService == null) { - final authProvider = context.read(); - _apiService = CovesApiService( - tokenGetter: authProvider.getAccessToken, - tokenRefresher: authProvider.refreshToken, - signOutHandler: authProvider.signOut, - ); - } - return _apiService!; - } - - Future _createCommunity() async { - if (!_isFormValid || _isSubmitting) return; - - // Validate DNS-valid name before API call - if (!_validateName()) return; - - setState(() { - _isSubmitting = true; - }); - - try { - final apiService = _getApiService(); - - final response = await apiService.createCommunity( - name: _nameController.text.trim().toLowerCase(), - displayName: _displayNameController.text.trim(), - description: _descriptionController.text.trim(), - ); - - if (mounted) { - setState(() { - _createdCommunities = [..._createdCommunities, response]; - _isSubmitting = false; - }); - - // Clear form - _nameController.clear(); - _displayNameController.clear(); - _descriptionController.clear(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Community created: ${response.handle}'), - backgroundColor: Colors.green[700], - behavior: SnackBarBehavior.floating, - ), - ); - } - } on ApiException catch (e) { - if (kDebugMode) { - debugPrint('API error creating community: ${e.message}'); - } - if (mounted) { - setState(() { - _isSubmitting = false; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to create community: ${e.message}'), - backgroundColor: Colors.red[700], - behavior: SnackBarBehavior.floating, - ), - ); - } - } catch (e, stackTrace) { - if (kDebugMode) { - debugPrint('Unexpected error in _createCommunity: $e'); - debugPrint('Stack trace: $stackTrace'); - } - if (mounted) { - setState(() { - _isSubmitting = false; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('An unexpected error occurred. Please try again.'), - backgroundColor: Colors.red, - behavior: SnackBarBehavior.floating, - ), - ); - } - } - } - @override Widget build(BuildContext context) { final authProvider = Provider.of(context); @@ -210,7 +22,10 @@ class _CommunitiesScreenState extends State { if (kDebugMode) { debugPrint('CommunitiesScreen: handle=$handle, isAdmin=$isAdmin'); - debugPrint('CommunitiesScreen: kAdminHandles=$kAdminHandles'); + } + + if (isAdmin) { + return const CommunitiesAdminPanel(); } return Scaffold( @@ -218,14 +33,20 @@ class _CommunitiesScreenState extends State { appBar: AppBar( backgroundColor: AppColors.background, foregroundColor: Colors.white, - title: Text(isAdmin ? 'Admin: Communities' : 'Communities'), + title: const Text('Communities'), automaticallyImplyLeading: false, ), - body: isAdmin ? _buildAdminUI() : _buildPlaceholderUI(), + body: const _CommunitiesPlaceholder(), ); } +} - Widget _buildPlaceholderUI() { +/// Placeholder UI for non-admin users +class _CommunitiesPlaceholder extends StatelessWidget { + const _CommunitiesPlaceholder(); + + @override + Widget build(BuildContext context) { return const Center( child: Padding( padding: EdgeInsets.all(24), @@ -253,229 +74,4 @@ class _CommunitiesScreenState extends State { ), ); } - - Widget _buildAdminUI() { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - const Text( - 'Create Community', - style: TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - const Text( - 'Create a new community for Coves users', - style: TextStyle(fontSize: 14, color: Color(0xFFB6C2D2)), - ), - const SizedBox(height: 24), - - // Name field (DNS-valid slug) - _buildTextField( - controller: _nameController, - label: 'Name (unique identifier)', - hint: 'worldnews', - helperText: 'DNS-valid, lowercase, no spaces', - errorText: _nameError, - ), - const SizedBox(height: 16), - - // Handle preview - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.backgroundSecondary, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.border), - ), - child: Row( - children: [ - const Icon(Icons.link, color: AppColors.primary, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - _handlePreview, - style: const TextStyle( - color: AppColors.primary, - fontFamily: 'monospace', - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Display Name field - _buildTextField( - controller: _displayNameController, - label: 'Display Name', - hint: 'World News', - helperText: 'Human-readable name shown in the UI', - ), - const SizedBox(height: 16), - - // Description field - _buildTextField( - controller: _descriptionController, - label: 'Description', - hint: 'Global news and current events from around the world', - maxLines: 3, - ), - const SizedBox(height: 24), - - // Create button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isFormValid && !_isSubmitting ? _createCommunity : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - disabledBackgroundColor: AppColors.backgroundSecondary, - ), - child: _isSubmitting - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Text( - 'Create Community', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - - // Created communities list - if (_createdCommunities.isNotEmpty) ...[ - const SizedBox(height: 32), - const Text( - 'Created Communities', - style: TextStyle( - fontSize: 18, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - ..._createdCommunities.map((community) => _buildCommunityTile(community)), - ], - ], - ), - ); - } - - Widget _buildTextField({ - required TextEditingController controller, - required String label, - required String hint, - String? helperText, - String? errorText, - int maxLines = 1, - }) { - final hasError = errorText != null; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: maxLines, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: hint, - hintStyle: TextStyle(color: Colors.white.withValues(alpha: 0.4)), - helperText: hasError ? null : helperText, - helperStyle: const TextStyle(color: Color(0xFFB6C2D2)), - errorText: errorText, - errorStyle: const TextStyle(color: Colors.red), - filled: true, - fillColor: AppColors.backgroundSecondary, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: hasError ? Colors.red : AppColors.border, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: hasError ? Colors.red : AppColors.primary, - ), - ), - ), - ), - ], - ); - } - - Widget _buildCommunityTile(CreateCommunityResponse community) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.backgroundSecondary, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.check_circle, color: Colors.green, size: 20), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - community.handle, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - Text( - community.did, - style: const TextStyle( - color: Color(0xFFB6C2D2), - fontSize: 12, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ); - } } diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index 728dea4..fa24113 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; @@ -824,6 +826,88 @@ class CovesApiService { } } + /// Update a community's profile (e.g., avatar) + /// + /// Updates a community's profile with a new avatar image. + /// Requires authentication and admin privileges (backend enforces). + /// + /// Parameters: + /// - [communityDid]: The DID of the community to update (required) + /// - [imageBytes]: The avatar image bytes (required, max 1 MB) + /// - [mimeType]: The MIME type of the image (required) + /// Supported: 'image/jpeg', 'image/png', 'image/webp' + /// + /// Returns [CreateCommunityResponse] with updated community info. + /// + /// Throws: + /// - [ApiException] if image exceeds 1 MB or has unsupported MIME type + /// - [AuthenticationException] if not authenticated + /// - [ApiException] for other API errors + Future updateCommunity({ + required String communityDid, + required Uint8List imageBytes, + required String mimeType, + }) async { + // Validate image size (max 1 MB) + const maxSizeBytes = 1024 * 1024; // 1 MB + if (imageBytes.length > maxSizeBytes) { + throw ApiException( + 'Image size exceeds maximum of 1 MB ' + '(${(imageBytes.length / 1024 / 1024).toStringAsFixed(2)} MB)', + ); + } + + // Validate MIME type + const supportedMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; + if (!supportedMimeTypes.contains(mimeType)) { + throw ApiException( + 'Unsupported image type: $mimeType. ' + 'Supported types: ${supportedMimeTypes.join(', ')}', + ); + } + + try { + if (kDebugMode) { + debugPrint( + '📡 Updating community avatar: $communityDid ' + '(${imageBytes.length} bytes, $mimeType)', + ); + } + + // Encode image bytes to base64 + final avatarBlob = base64Encode(imageBytes); + + final requestBody = { + 'communityDid': communityDid, + 'avatarBlob': avatarBlob, + 'avatarMimeType': mimeType, + }; + + final response = await _dio.post( + '/xrpc/social.coves.community.update', + data: requestBody, + ); + + if (kDebugMode) { + debugPrint('✅ Community avatar updated successfully'); + } + + return CreateCommunityResponse.fromJson( + response.data as Map, + ); + } on DioException catch (e) { + _handleDioException(e, 'update community'); + } catch (e) { + if (e is ApiException) { + rethrow; + } + if (kDebugMode) { + debugPrint('❌ Error updating community: $e'); + } + throw ApiException('Failed to update community', originalError: e); + } + } + /// Handle Dio exceptions with specific error types /// /// Converts generic DioException into specific typed exceptions diff --git a/pubspec.lock b/pubspec.lock index 9053e37..8fbe022 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -265,6 +265,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -302,6 +334,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_secure_storage: dependency: "direct main" description: @@ -464,6 +504,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "297e42bd236c4ac4b091d4277292159b3280545e030cae2be3d503f9ecf7e6a1" + url: "https://pub.dev" + source: hosted + version: "0.8.13+12" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7677be2..6210f43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: video_player: ^2.8.7 # Pinned for stability share_plus: ^10.1.4 sentry_flutter: ^8.10.1 + image_picker: ^1.1.2 dev_dependencies: flutter_test: -- 2.51.2