diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 38d44794..4554a48f 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/task.dart'; import '../models/task_activity_log.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/task_assignment.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; @@ -210,6 +213,10 @@ final tasksControllerProvider = Provider((ref) { class TasksController { TasksController(this._client); + // Supabase storage bucket for task action images. Ensure this bucket exists + // with public read access. + static const String _actionImageBucket = 'task_action_taken_images'; + // _client is declared dynamic allowing test doubles that mimic only the // subset of methods used by this class. In production it will be a // SupabaseClient instance. @@ -277,6 +284,114 @@ class TasksController { unawaited(_notifyCreated(taskId: taskId, actorId: actorId)); } + /// Uploads an image for a task's action field and returns the public URL. + /// + /// [bytes] should contain the file data and [extension] the file extension + /// (e.g. 'png' or 'jpg'). The image will be stored under a path that + /// includes the task ID and a timestamp to avoid collisions. Returns `null` + /// if the upload fails. + Future uploadActionImage({ + required String taskId, + required Uint8List bytes, + required String extension, + }) async { + final path = + 'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension'; + try { + // debug: show upload path + // ignore: avoid_print + print('uploadActionImage uploading to path: $path'); + // perform the upload and capture whatever the SDK returns (it varies by platform) + final dynamic res; + if (kIsWeb) { + // on web, upload binary data + res = await _client.storage + .from(_actionImageBucket) + .uploadBinary(path, bytes); + } else { + // write bytes to a simple temp file (no nested folders) + final tmpDir = Directory.systemTemp; + final localFile = File( + '${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}.$extension', + ); + try { + await localFile.create(); + await localFile.writeAsBytes(bytes); + } catch (e) { + // ignore: avoid_print + print('uploadActionImage failed writing temp file: $e'); + return null; + } + res = await _client.storage + .from(_actionImageBucket) + .upload(path, localFile); + try { + await localFile.delete(); + } catch (_) {} + } + + // debug: inspect the response object/type + // ignore: avoid_print + print('uploadActionImage response type=${res.runtimeType} value=$res'); + + // Some SDK methods return a simple String (path) on success, others + // return a StorageResponse with an error field. Avoid calling .error on a + // String to prevent NoSuchMethodError as seen in logs earlier. + if (res is String) { + // treat as success + } else if (res is Map && res['error'] != null) { + // older versions might return a plain map + // ignore: avoid_print + print('uploadActionImage upload error: ${res['error']}'); + return null; + } else if (res != null && res.error != null) { + // StorageResponse case + // ignore: avoid_print + print('uploadActionImage upload error: ${res.error}'); + return null; + } + } catch (e) { + // ignore: avoid_print + print('uploadActionImage failed upload: $e'); + return null; + } + try { + final urlRes = await _client.storage + .from(_actionImageBucket) + .getPublicUrl(path); + // debug: log full response + // ignore: avoid_print + print('uploadActionImage getPublicUrl response: $urlRes'); + + String? url; + if (urlRes is String) { + url = urlRes; + } else if (urlRes is Map && urlRes['data'] is String) { + url = urlRes['data'] as String; + } else if (urlRes != null) { + try { + url = urlRes.data as String?; + } catch (_) { + url = null; + } + } + + if (url != null && url.isNotEmpty) { + // trim whitespace/newline which may be added by SDK or logging + return url.trim(); + } + // fallback: construct URL manually using env variable + final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; + if (supabaseUrl.isEmpty) return null; + return '$supabaseUrl/storage/v1/object/public/$_actionImageBucket/$path' + .trim(); + } catch (e) { + // ignore: avoid_print + print('uploadActionImage getPublicUrl error: $e'); + return null; + } + } + Future _notifyCreated({ required String taskId, required String? actorId, diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index a4ce5c92..26c2eba8 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -1,3 +1,4 @@ +// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -1612,25 +1613,62 @@ class _TaskDetailScreenState extends ConsumerState if (bytes == null) { return; } - final b64 = - base64Encode(bytes); final ext = file.extension ?? 'png'; - final dataUri = - 'data:image/$ext;base64,$b64'; + final messenger = + ScaffoldMessenger.of( + context, + ); + String? url; + try { + url = await ref + .read( + tasksControllerProvider, + ) + .uploadActionImage( + taskId: task.id, + bytes: bytes, + extension: ext, + ); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text( + 'Upload error: $e', + ), + ), + ); + return; + } + if (url == null) { + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Image upload failed (no URL returned)', + ), + ), + ); + return; + } + final trimmedUrl = url + .trim(); final idx = _actionController ?.selection .baseOffset ?? 0; + // ignore: avoid_print + print( + 'inserting image embed idx=$idx url=$trimmedUrl', + ); _actionController ?.document .insert( idx, quill .BlockEmbed.image( - dataUri, + trimmedUrl, ), ); } catch (_) {}