creek

The AI Image Editor of 2030
commit 2f17d4309374dc7726f7c5977badd70c8bf00500
parent b4aeda83368f0bc488e734ae63a34fd67e5301c9
Author: maydayv7 <maydayv7@gmail.com>
Date:   Fri, 28 Nov 2025 00:07:03 +0530

Merge remote-tracking branch 'origin/avni'

Diffstat:

Mandroid/app/src/main/AndroidManifest.xml | 71+++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mlib/data/repos/project_repo.dart | 24++++++++++++++++++++++++
Mlib/main.dart | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Alib/ui/pages/moodboards_page.dart | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/pages/share_handler_page.dart | 268++++++++++++++++++++++++++++---------------------------------------------------
Alib/ui/pages/share_to_moodboard_page.dart | 434+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1029 insertions(+), 220 deletions(-)

diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml @@ -1,61 +1,87 @@ -<manifest xmlns:android="http://schemas.android.com/apk/res/android"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.example.adobe"> + <uses-permission android:name="android.permission.INTERNET"/> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32"/> + + <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> + <uses-permission android:name="android.permission.CAMERA"/> + <application - android:label="adobe" + android:label="Adobe" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> - <!-- Specifies an Android theme to apply to this Activity as soon as - the Android process has started. This theme is visible to the user - while the Flutter UI initializes. After that, this theme continues - to determine the Window background behind the Flutter UI. --> - <meta-data - android:name="io.flutter.embedding.android.NormalTheme" - android:resource="@style/NormalTheme" - /> + <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> - + + <meta-data + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> + </activity> + + <activity-alias + android:name=".ShareToFiles" + android:targetActivity=".MainActivity" + android:label="Files" + android:icon="@mipmap/ic_launcher" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.SEND" /> <category android:name="android.intent.category.DEFAULT" /> - <data android:mimeType="text/*"/> + <data android:mimeType="image/*" /> + <data android:mimeType="text/*" /> </intent-filter> + <intent-filter> + <action android:name="android.intent.action.SEND_MULTIPLE" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="image/*" /> + </intent-filter> + </activity-alias> + <activity-alias + android:name=".ShareToMoodboards" + android:targetActivity=".MainActivity" + android:label="Moodboards" + android:icon="@mipmap/ic_launcher" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.SEND" /> <category android:name="android.intent.category.DEFAULT" /> - <data android:mimeType="image/*"/> + <data android:mimeType="image/*" /> + <data android:mimeType="text/*" /> </intent-filter> - </activity> - <!-- Don't delete the meta-data below. - This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> + <intent-filter> + <action android:name="android.intent.action.SEND_MULTIPLE" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="image/*" /> + </intent-filter> + </activity-alias> + <meta-data android:name="flutterEmbedding" android:value="2" /> </application> - <!-- Required to query activities that can process text, see: - https://developer.android.com/training/package-visibility and - https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. - In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> <queries> <intent> <action android:name="android.intent.action.PROCESS_TEXT"/> <data android:mimeType="text/plain"/> </intent> </queries> -</manifest> +</manifest> +\ No newline at end of file diff --git a/lib/data/repos/project_repo.dart b/lib/data/repos/project_repo.dart @@ -18,6 +18,23 @@ class ProjectRepo { return res.map((e) => ProjectModel.fromMap(e)).toList(); } + Future<List<ProjectModel>> getRecentProjectsAndEvents() async { + final db = await AppDatabase.db; + final res = await db.query( + 'projects', + // No 'where parent_id is null' check here, we want everything + orderBy: 'last_accessed_at DESC', + limit: 10, + ); + return res.map((e) => ProjectModel.fromMap(e)).toList(); + } + + Future<List<ProjectModel>> getAllProjectsAndEvents() async { + final db = await AppDatabase.db; + final res = await db.query('projects', orderBy: 'title ASC'); + return res.map((e) => ProjectModel.fromMap(e)).toList(); + } + Future<List<ProjectModel>> getAllProjects() async { final db = await AppDatabase.db; final res = await db.query( @@ -39,6 +56,13 @@ class ProjectRepo { return res.map((e) => ProjectModel.fromMap(e)).toList(); } + Future<ProjectModel?> getProjectById(int id) async { + final db = await AppDatabase.db; + final res = await db.query('projects', where: 'id = ?', whereArgs: [id]); + if (res.isNotEmpty) return ProjectModel.fromMap(res.first); + return null; + } + Future<void> updateProject( int id, { String? title, diff --git a/lib/main.dart b/lib/main.dart @@ -1,14 +1,13 @@ import 'dart:async'; +import 'dart:io'; // Required for File handling import 'package:flutter/material.dart'; -import 'package:adobe/services/theme_service.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:adobe/services/theme_service.dart'; +import 'package:adobe/ui/pages/share_to_moodboard_page.dart'; // Share Page import 'package:adobe/ui/pages/home_page.dart'; -// import 'package:adobe/ui/pages/share_handler_page.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - // Removed await calls here to prevent startup freeze. - // Initialization logic is now handled inside MyApp. runApp(const MyApp()); } @@ -21,7 +20,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State<MyApp> { late StreamSubscription _intentSub; - final _navigatorKey = GlobalKey<NavigatorState>(); + final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>(); // Controls the loading screen bool _isReady = false; @@ -33,7 +32,7 @@ class _MyAppState extends State<MyApp> { } Future<void> _initApp() async { - // 1. Load Theme (Wrapped in try-catch to be safe) + // 1. Load Theme try { await themeService.loadTheme(); } catch (e) { @@ -46,7 +45,7 @@ class _MyAppState extends State<MyApp> { _intentSub = ReceiveSharingIntent.instance.getMediaStream().listen( (List<SharedMediaFile> value) { if (value.isNotEmpty) { - _navigateToSharePage(value.first.path); + _showShareOptions(value); } }, onError: (err) { @@ -58,7 +57,8 @@ class _MyAppState extends State<MyApp> { final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia(); if (initialMedia.isNotEmpty) { - _navigateToSharePage(initialMedia.first.path); + _showShareOptions(initialMedia); + // Important: Reset immediately to avoid re-triggering on reload ReceiveSharingIntent.instance.reset(); } } catch (e) { @@ -79,6 +79,95 @@ class _MyAppState extends State<MyApp> { if (mounted) setState(() {}); } + // --- SHARE HANDLING LOGIC --- + + void _showShareOptions(List<SharedMediaFile> files) { + // We use the navigator key to get context because 'context' might not be valid in async callbacks + final context = _navigatorKey.currentState?.overlay?.context; + + // Safety check: ensure we have context and a file + if (context == null || files.isEmpty) return; + + final File imageFile = File(files.first.path); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Wrap( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 20, 20, 10), + child: Text( + "Save Image To...", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + + // Option 1: Moodboard + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.dashboard_customize, + color: Colors.blueAccent, + ), + ), + title: const Text("Moodboard"), + subtitle: const Text("Add to references & analysis"), + onTap: () { + Navigator.pop(context); // Close Bottom Sheet + + // Navigate to ShareToMoodboardPage + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: + (_) => ShareToMoodboardPage(imageFile: imageFile), + ), + ); + }, + ), + + // Option 2: Project Files + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orangeAccent.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.folder_copy, + color: Colors.orangeAccent, + ), + ), + title: const Text("Project Files"), + subtitle: const Text("Add to canvas assets"), + onTap: () { + Navigator.pop(context); // Close Bottom Sheet + + // Feature Placeholder + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Files feature coming soon!")), + ); + }, + ), + const SizedBox(height: 20), + ], + ), + ); + }, + ); + } + @override void dispose() { try { @@ -88,22 +177,9 @@ class _MyAppState extends State<MyApp> { super.dispose(); } - void _navigateToSharePage(String sharedText) { - // Wait for the frame to render before pushing the route - WidgetsBinding.instance.addPostFrameCallback((_) { - /* - _navigatorKey.currentState?.push( - MaterialPageRoute( - builder: (_) => ShareHandlerPage(sharedText: sharedText), - ), - ); - */ - }); - } - @override Widget build(BuildContext context) { - // Show a loading spinner until essential services are initialized + // Loading Screen if (!_isReady) { return const MaterialApp( debugShowCheckedModeBanner: false, @@ -115,13 +191,13 @@ class _MyAppState extends State<MyApp> { } return MaterialApp( - navigatorKey: _navigatorKey, + navigatorKey: + _navigatorKey, // Critical for global navigation from Share Intent debugShowCheckedModeBanner: false, - // Connect the ThemeService mode (Light / Dark / System) + // Theme Configuration themeMode: themeService.mode, - // Light Theme Definition theme: ThemeData( fontFamily: 'GeneralSans', colorScheme: ColorScheme.fromSeed( @@ -137,7 +213,6 @@ class _MyAppState extends State<MyApp> { ), ), - // Dark Theme Definition darkTheme: ThemeData( fontFamily: 'GeneralSans', colorScheme: ColorScheme.fromSeed( diff --git a/lib/ui/pages/moodboards_page.dart b/lib/ui/pages/moodboards_page.dart @@ -0,0 +1,325 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:adobe/data/repos/board_repo.dart'; +import 'package:adobe/data/repos/board_image_repo.dart'; + +class MoodboardsPage extends StatefulWidget { + const MoodboardsPage({super.key}); + + @override + State<MoodboardsPage> createState() => _MoodboardsPageState(); +} + +class _MoodboardsPageState extends State<MoodboardsPage> { + final _boardRepo = BoardRepository(); + final _boardImageRepo = BoardImageRepository(); + final _searchController = TextEditingController(); + + List<Map<String, dynamic>> _allBoards = []; + List<Map<String, dynamic>> _filteredBoards = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadData(); + _searchController.addListener(_onSearchChanged); + } + + Future<void> _loadData() async { + // 1. Fetch Boards + final boards = await _boardRepo.getBoards(); + + // 2. (Optional) Fetch a thumbnail image for each board to make it look good + // This is a simulated join. In a real app, do this in the SQL query. + List<Map<String, dynamic>> enrichedBoards = []; + + for (var board in boards) { + // Get the first image of this board to serve as cover + final images = await _boardImageRepo.getImagesForBoard(board['id']); + String? coverPath; + if (images.isNotEmpty) { + coverPath = images.first['filePath']; + } + + enrichedBoards.add({ + ...board, + 'coverPath': coverPath, + // If your DB doesn't have a 'project' column yet, we mock it for the UI demo + // You should add 'project' TEXT to your board_repo.dart create table + 'project': board['project'] ?? 'General Projects', + }); + } + + if (mounted) { + setState(() { + _allBoards = enrichedBoards; + _filteredBoards = enrichedBoards; + _isLoading = false; + }); + } + } + + void _onSearchChanged() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filteredBoards = + _allBoards.where((board) { + final name = (board['name'] as String).toLowerCase(); + final project = (board['project'] as String).toLowerCase(); + return name.contains(query) || project.contains(query); + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, // Match screenshot background + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios_new, + size: 20, + color: Colors.black, + ), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + "MoodBoards", + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: Colors.black), + onPressed: () { + // Add new board logic + }, + ), + ], + ), + body: + _isLoading + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ListView( + physics: const BouncingScrollPhysics(), + children: [ + const SizedBox(height: 10), + + // --- 1. SEARCH BAR --- + Container( + decoration: BoxDecoration( + color: Colors.grey[200], // Match Screenshot grey + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + hintText: "Search", + prefixIcon: Icon(Icons.search, color: Colors.grey), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + + const SizedBox(height: 24), + + // --- 2. RECENT PROJECTS --- + const Text( + "Recent Projects/Events", + style: TextStyle( + fontSize: 14, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + + // Take top 3 items for "Recent" + ..._filteredBoards + .take(3) + .map((board) => _buildRecentCard(board)), + + const SizedBox(height: 24), + + // --- 3. ALL PROJECTS (Accordion Style) --- + const Text( + "All Projects/Events", + style: TextStyle( + fontSize: 14, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + + _buildAllProjectsList(), + + const SizedBox(height: 40), + ], + ), + ), + ); + } + + // --- WIDGET: The Card for Recent Items --- + Widget _buildRecentCard(Map<String, dynamic> board) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: ListTile( + contentPadding: const EdgeInsets.all(8), + leading: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.grey[100], + image: + board['coverPath'] != null + ? DecorationImage( + image: FileImage(File(board['coverPath'])), + fit: BoxFit.cover, + ) + : null, + ), + child: + board['coverPath'] == null + ? Icon(Icons.dashboard, color: Colors.grey[400]) + : null, + ), + title: Text( + board['project'].toString(), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + subtitle: Text( + board['name'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + onTap: () { + // Navigate to Board Details + }, + ), + ); + } + + // --- WIDGET: The Accordion List for "All Projects" --- + Widget _buildAllProjectsList() { + // 1. Group boards by 'Project' + // { "Bakery": [board1, board2], "Run Club": [board3] } + Map<String, List<Map<String, dynamic>>> grouped = {}; + + for (var board in _filteredBoards) { + String project = board['project'] ?? "Uncategorized"; + if (!grouped.containsKey(project)) { + grouped[project] = []; + } + grouped[project]!.add(board); + } + + // 2. Build Expansion Tiles + return Column( + children: + grouped.entries.map((entry) { + String projectName = entry.key; + List<Map<String, dynamic>> projectBoards = entry.value; + + // Generate a random-ish color for the avatar based on name length + Color avatarColor = + Colors + .primaries[projectName.length % Colors.primaries.length] + .shade100; + Color textColor = + Colors + .primaries[projectName.length % Colors.primaries.length] + .shade700; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: + Colors.grey[50], // Very light grey background for the group + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: ExpansionTile( + shape: const Border(), // Remove default divider borders + collapsedShape: const Border(), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: avatarColor, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + projectName.substring(0, 1).toUpperCase(), // First letter + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ), + ), + title: Text( + projectName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + children: + projectBoards.map((board) { + return Container( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Colors.white)), + ), + child: ListTile( + contentPadding: const EdgeInsets.only( + left: 70, + right: 20, + bottom: 8, + ), + title: Text( + board['name'], + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 14, + color: Colors.grey, + ), + onTap: () { + // Navigate to Board Details + }, + ), + ); + }).toList(), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/ui/pages/share_handler_page.dart b/lib/ui/pages/share_handler_page.dart @@ -1,17 +1,11 @@ import 'dart:io'; -import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:uuid/uuid.dart'; import 'package:adobe/services/download_service.dart'; import 'package:adobe/services/instagram_download_service.dart'; -import 'package:adobe/data/repos/board_repo.dart'; -import 'package:adobe/data/repos/board_image_repo.dart'; -import 'package:adobe/data/repos/image_repo.dart'; -import 'package:adobe/services/image_analyzer_service.dart'; // Master Runner +import 'package:adobe/ui/pages/share_to_moodboard_page.dart'; class ShareHandlerPage extends StatefulWidget { - final String sharedText; + final String sharedText; const ShareHandlerPage({super.key, required this.sharedText}); @@ -20,200 +14,130 @@ class ShareHandlerPage extends StatefulWidget { } class _ShareHandlerPageState extends State<ShareHandlerPage> { + // Services final _downloadService = DownloadService(); final _instagramService = InstagramDownloadService(); - final _boardRepo = BoardRepository(); - final _boardImageRepo = BoardImageRepository(); - final _imageRepo = ImageRepository(); - final _uuid = const Uuid(); - List<String> _downloadedImageIds = []; - bool _isDownloading = true; + // State + bool _isProcessing = true; bool _hasError = false; String? _errorMessage; - List<Map<String, dynamic>> _boards = []; @override void initState() { super.initState(); - _startProcess(); + _processSharedContent(); } - void _startProcess() async { - _boards = await _boardRepo.getBoards(); - final sharedPath = widget.sharedText.trim(); + Future<void> _processSharedContent() async { + final sharedContent = widget.sharedText.trim(); + File? finalFile; - if (File(sharedPath).existsSync()) { - try { - final id = await _handleDirectImage(sharedPath); - if (mounted) { - setState(() { - if (id != null) _downloadedImageIds = [id]; - _isDownloading = false; - if (id == null) { - _hasError = true; - _errorMessage = "Failed to save image"; - } - }); - } - } catch (e) { - if (mounted) setState(() { _hasError = true; _errorMessage = "$e"; _isDownloading = false; }); + try { + // CASE A: It's a Local File Path + if (await File(sharedContent).exists()) { + finalFile = File(sharedContent); } - } else { - // URL Handling - final urlRegExp = RegExp(r'(https?://\S+)'); - final match = urlRegExp.firstMatch(sharedPath); + // CASE B: It's a URL + else { + final urlRegExp = RegExp(r'(https?://\S+)'); + final match = urlRegExp.firstMatch(sharedContent); + + if (match != null) { + final url = match.group(0)!; - if (match != null) { - final url = match.group(0)!; - try { if (url.contains('instagram.com')) { - final ids = await _instagramService.downloadInstagramImage(url); - if (mounted) { - setState(() { - _downloadedImageIds = ids ?? []; - _isDownloading = false; - if (_downloadedImageIds.isEmpty) { _hasError = true; _errorMessage = "Insta download failed"; } - }); + // Instagram Logic + // Assuming the service returns a List of file paths + final downloadedPaths = await _instagramService + .downloadInstagramImage(url); + + if (downloadedPaths != null && downloadedPaths.isNotEmpty) { + finalFile = File(downloadedPaths.first); } } else { - final id = await _downloadService.downloadAndSaveImage(url); - if (mounted) { - setState(() { - if (id != null) _downloadedImageIds = [id]; - _isDownloading = false; - if (id == null) { _hasError = true; _errorMessage = "Download failed"; } - }); + // Generic Download Logic + // Ensure this service returns the String path of the saved file + final savedPath = await _downloadService.downloadAndSaveImage(url); + + if (savedPath != null) { + finalFile = File(savedPath); } } - } catch (e) { - if (mounted) setState(() { _hasError = true; _errorMessage = "$e"; _isDownloading = false; }); + } else { + throw Exception("Invalid content: Not a valid file path or URL."); } - } else { - if (mounted) setState(() { _hasError = true; _errorMessage = "Invalid URL"; _isDownloading = false; }); - } - } - } - - Future<String?> _handleDirectImage(String filePath) async { - final file = File(filePath); - if (!await file.exists()) return null; - - final dir = await getApplicationDocumentsDirectory(); - final imagesDir = Directory('${dir.path}/images'); - if (!await imagesDir.exists()) await imagesDir.create(recursive: true); - - final extension = filePath.split('.').last; - final imageId = _uuid.v4(); - final targetPath = '${imagesDir.path}/$imageId.$extension'; - - await file.copy(targetPath); - await _imageRepo.insertImage(imageId, targetPath); - return imageId; - } - - Future<void> _saveToBoard(int boardId) async { - if (_downloadedImageIds.isEmpty) return; - - for (final id in _downloadedImageIds) { - await _boardImageRepo.saveToBoard(boardId, id); - } - - final images = await _imageRepo.getAllImages(); - - for (final id in _downloadedImageIds) { - final imageData = images.firstWhere((img) => img['id'] == id, orElse: () => <String, dynamic>{}); - if (imageData.isNotEmpty && imageData['filePath'] != null) { - // Trigger Background Analysis - _analyzeImageInBackground(id, imageData['filePath'] as String); } - } - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Saved ${_downloadedImageIds.length} image(s)! Analysis running.")), - ); - Navigator.popUntil(context, (route) => route.isFirst); - } - Future<void> _analyzeImageInBackground(String imageId, String imagePath) async { - try { - // Changed to use Master Runner - final result = await ImageAnalyzerService.analyzeFullSuite(imagePath); - if (result['success'] == true) { - await _imageRepo.updateImageAnalysis(imageId, json.encode(result)); + // SUCCESS: Navigate to ShareToMoodboardPage + if (finalFile != null && await finalFile.exists()) { + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ShareToMoodboardPage(imageFile: finalFile!), + ), + ); + } + } else { + throw Exception("Could not retrieve image file."); } } catch (e) { - debugPrint('Background Analysis Error: $e'); + if (mounted) { + setState(() { + _hasError = true; + _errorMessage = e.toString(); + _isProcessing = false; + }); + } } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text("Save to Board")), - body: _buildBody(), - ); - } - - Widget _buildBody() { - if (_hasError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error, color: Colors.red, size: 48), - Text(_errorMessage ?? "Error"), - ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text("Back")) - ], - ), - ); - } - if (_isDownloading) return const Center(child: CircularProgressIndicator()); - - return ListView.builder( - itemCount: _boards.length + 2, - itemBuilder: (context, index) { - if (index == 0) return const Padding(padding: EdgeInsets.all(16), child: Text("Select a Board")); - if (index == 1) { - return ListTile( - leading: const Icon(Icons.add_box, color: Colors.red), - title: const Text("Create New Board"), - onTap: _showCreateBoardDialog, - ); - } - - final board = _boards[index - 2]; - return ListTile( - leading: const Icon(Icons.dashboard), - title: Text(board['name']), - onTap: () => _saveToBoard(board['id']), - ); - }, - ); - } - - void _showCreateBoardDialog() { - final controller = TextEditingController(); - showDialog( - context: context, - builder: (c) => AlertDialog( - title: const Text("New Board"), - content: TextField(controller: controller, decoration: const InputDecoration(hintText: "Name")), - actions: [ - TextButton(onPressed: () => Navigator.pop(c), child: const Text("Cancel")), - ElevatedButton( - onPressed: () async { - if (controller.text.isNotEmpty) { - final newId = await _boardRepo.createBoard(controller.text); - if (!c.mounted) return; - Navigator.pop(c); - _saveToBoard(newId); - } - }, - child: const Text("Create"), - ), - ], + backgroundColor: Colors.white, + appBar: AppBar(title: const Text("Processing...")), + body: Center( + child: + _hasError + ? Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 50, + ), + const SizedBox(height: 16), + Text( + "Error processing media", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? "Unknown Error", + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text("Close"), + ), + ], + ), + ) + : const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text("Analyzing & Preparing Image..."), + ], + ), ), ); } diff --git a/lib/ui/pages/share_to_moodboard_page.dart b/lib/ui/pages/share_to_moodboard_page.dart @@ -0,0 +1,434 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +// Models & Repos +import '../../data/models/project_model.dart'; +import '../../data/repos/project_repo.dart'; + +// Services +import '../../services/project_service.dart'; +import '../../services/image_service.dart'; // <--- CHANGED: Import ImageService + +// Helper model for UI rendering +class ProjectItemViewModel { + final ProjectModel item; + final String? parentTitle; + + ProjectItemViewModel({required this.item, this.parentTitle}); + + String get title => item.title; + bool get isEvent => item.isEvent; + int get id => item.id!; +} + +class ShareToMoodboardPage extends StatefulWidget { + final File imageFile; + + const ShareToMoodboardPage({super.key, required this.imageFile}); + + @override + State<ShareToMoodboardPage> createState() => _ShareToMoodboardPageState(); +} + +class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { + final ProjectRepo _projectRepo = ProjectRepo(); + final ProjectService _projectService = ProjectService(); + + // FIXED: Use ImageService (which handles Moodboard Images) + final ImageService _imageService = ImageService(); + + List<ProjectItemViewModel> _recentViewModels = []; + List<ProjectItemViewModel> _allViewModels = []; + List<ProjectItemViewModel> _filteredViewModels = []; + + bool _isLoading = true; + bool _isSaving = false; + String _searchQuery = ""; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future<void> _loadData() async { + setState(() => _isLoading = true); + + // Fetch raw data + final recentItems = await _projectRepo.getRecentProjectsAndEvents(); + final allItems = await _projectRepo.getAllProjectsAndEvents(); + + // Build view models with parent titles + _recentViewModels = await _buildViewModels(recentItems.take(3).toList()); + _allViewModels = await _buildViewModels(allItems); + _filteredViewModels = _allViewModels; + + setState(() => _isLoading = false); + } + + Future<List<ProjectItemViewModel>> _buildViewModels( + List<ProjectModel> items, + ) async { + final List<ProjectItemViewModel> viewModels = []; + for (var item in items) { + String? parentTitle; + if (item.parentId != null) { + final parent = await _projectRepo.getProjectById(item.parentId!); + parentTitle = parent?.title; + } + viewModels.add( + ProjectItemViewModel(item: item, parentTitle: parentTitle), + ); + } + return viewModels; + } + + void _filterProjects(String query) { + setState(() { + _searchQuery = query; + if (query.isEmpty) { + _filteredViewModels = _allViewModels; + } else { + _filteredViewModels = + _allViewModels.where((vm) { + final titleMatches = vm.title.toLowerCase().contains( + query.toLowerCase(), + ); + final parentMatches = + vm.parentTitle?.toLowerCase().contains(query.toLowerCase()) ?? + false; + return titleMatches || parentMatches; + }).toList(); + } + }); + } + + Future<void> _createNewProject() async { + final controller = TextEditingController(); + final String? title = await showDialog<String>( + context: context, + builder: + (context) => AlertDialog( + title: const Text("New Project"), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: "Project Title"), + autofocus: true, + textCapitalization: TextCapitalization.sentences, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Cancel"), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text("Create"), + ), + ], + ), + ); + + if (title != null && title.isNotEmpty) { + // Create the project and save the image to it immediately + final newId = await _projectService.createProject(title); + await _saveAndExit(newId); + } + } + + Future<void> _saveAndExit(int projectId) async { + setState(() => _isSaving = true); + + try { + // 1. Save Image to Moodboard (Using ImageService) + await _imageService.saveImage(widget.imageFile, projectId); + + // 2. Update "Recently Used" status + await _projectService.openProject(projectId); + + // 3. Reset Share Intent + ReceiveSharingIntent.instance.reset(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Saved to Moodboard!"), + backgroundColor: Colors.green, + ), + ); + Navigator.popUntil( + context, + (route) => route.isFirst, + ); // Go back to home + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Error saving: $e"), + backgroundColor: Colors.red, + ), + ); + setState(() => _isSaving = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + "MoodBoards", + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: Colors.black, size: 28), + onPressed: _createNewProject, + tooltip: "Create New Project", + ), + ], + ), + body: + _isLoading || _isSaving + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + // --- Search Bar --- + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: TextField( + controller: _searchController, + onChanged: _filterProjects, + decoration: InputDecoration( + hintText: "Search", + prefixIcon: const Icon( + Icons.search, + color: Colors.grey, + ), + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), + suffixIcon: + _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon( + Icons.clear, + color: Colors.grey, + ), + onPressed: () { + _searchController.clear(); + _filterProjects(""); + }, + ) + : null, + ), + ), + ), + + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- Recent Projects/Events Section --- + if (_searchQuery.isEmpty && + _recentViewModels.isNotEmpty) ...[ + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + "Recent Projects/Events", + style: TextStyle( + fontSize: 16, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 90, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemCount: _recentViewModels.length, + separatorBuilder: + (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + return _buildRecentItem( + _recentViewModels[index], + ); + }, + ), + ), + const SizedBox(height: 24), + ], + + // --- All Projects/Events Section --- + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + _searchQuery.isEmpty + ? "All Projects/Events" + : "Search Results", + style: const TextStyle( + fontSize: 16, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _filteredViewModels.length, + itemBuilder: (context, index) { + return _buildAllItem(_filteredViewModels[index]); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ); + } + + // --- Widget for Recent Items (Horizontal List) --- + Widget _buildRecentItem(ProjectItemViewModel vm) { + return InkWell( + onTap: () => _saveAndExit(vm.id), + borderRadius: BorderRadius.circular(16), + child: Container( + width: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.image, color: Colors.grey[400], size: 30), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (vm.isEvent && vm.parentTitle != null) + Text( + vm.parentTitle!, + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + vm.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } + + // --- Widget for All Items (Vertical List) --- + Widget _buildAllItem(ProjectItemViewModel vm) { + return ListTile( + onTap: () => _saveAndExit(vm.id), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + vm.isEvent ? Icons.event : Icons.folder, + color: Colors.grey[500], + ), + ), + title: + vm.isEvent && vm.parentTitle != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + vm.parentTitle!, + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), + Text( + vm.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ) + : Text( + vm.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ); + } +}