handled image insertion in action taken

This commit is contained in:
Marc Rejohn Castillano 2026-02-21 20:12:30 +08:00
parent 1b2b89d506
commit 8bb69a80af
2 changed files with 158 additions and 5 deletions

View File

@ -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<TasksController>((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<String?> 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<void> _notifyCreated({
required String taskId,
required String? actorId,

View File

@ -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<TaskDetailScreen>
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 (_) {}