handled image insertion in action taken
This commit is contained in:
parent
1b2b89d506
commit
8bb69a80af
|
|
@ -1,4 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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.dart';
|
||||||
import '../models/task_activity_log.dart';
|
import '../models/task_activity_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import '../models/task_assignment.dart';
|
import '../models/task_assignment.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
|
|
@ -210,6 +213,10 @@ final tasksControllerProvider = Provider<TasksController>((ref) {
|
||||||
class TasksController {
|
class TasksController {
|
||||||
TasksController(this._client);
|
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
|
// _client is declared dynamic allowing test doubles that mimic only the
|
||||||
// subset of methods used by this class. In production it will be a
|
// subset of methods used by this class. In production it will be a
|
||||||
// SupabaseClient instance.
|
// SupabaseClient instance.
|
||||||
|
|
@ -277,6 +284,114 @@ class TasksController {
|
||||||
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
|
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({
|
Future<void> _notifyCreated({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
required String? actorId,
|
required String? actorId,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// ignore_for_file: use_build_context_synchronously
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
|
@ -1612,25 +1613,62 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final b64 =
|
|
||||||
base64Encode(bytes);
|
|
||||||
final ext =
|
final ext =
|
||||||
file.extension ??
|
file.extension ??
|
||||||
'png';
|
'png';
|
||||||
final dataUri =
|
final messenger =
|
||||||
'data:image/$ext;base64,$b64';
|
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 =
|
final idx =
|
||||||
_actionController
|
_actionController
|
||||||
?.selection
|
?.selection
|
||||||
.baseOffset ??
|
.baseOffset ??
|
||||||
0;
|
0;
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(
|
||||||
|
'inserting image embed idx=$idx url=$trimmedUrl',
|
||||||
|
);
|
||||||
_actionController
|
_actionController
|
||||||
?.document
|
?.document
|
||||||
.insert(
|
.insert(
|
||||||
idx,
|
idx,
|
||||||
quill
|
quill
|
||||||
.BlockEmbed.image(
|
.BlockEmbed.image(
|
||||||
dataUri,
|
trimmedUrl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user