art_adk 1.0.5
art_adk: ^1.0.5 copied to clipboard
Flutter SDK for ART realtime communication with WebSocket channels, AI Agents, AI Orchestrators, presence tracking, end-to-end encrypted messaging, and CRDT-backed shared objects.
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:art_adk/art_adk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb, Uint8List;
import 'package:url_launcher/url_launcher.dart';
/// ─────────────────────────────────────────────────────────────────────────────
/// 0. Configure these three values for your environment
/// ─────────────────────────────────────────────────────────────────────────────
const String kServerUri = 'YOUR_WEBSOCKET_URI';
const String kPasscodeEndpoint = 'PASSCODE_ENDPOINT';
const String kUsername = 'USER_NAME';
const String kChannel = 'YOUR_CHANNEL_NAME';
void main() => runApp(const AdkExampleApp());
class AdkExampleApp extends StatelessWidget {
const AdkExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ART ADK Example',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const AdkHomePage(),
);
}
}
/// ─────────────────────────────────────────────────────────────────────────────
/// Home page — walks through every major ADK feature via buttons.
/// ─────────────────────────────────────────────────────────────────────────────
class AdkHomePage extends StatefulWidget {
const AdkHomePage({super.key});
@override
State<AdkHomePage> createState() => _AdkHomePageState();
}
class _AdkHomePageState extends State<AdkHomePage> {
Adk? _adk;
BaseSubscription? _subscription;
final List<String> _log = <String>[];
String? _selectedUser;
//Agent
Agent? _agent;
AgentThread? _thread;
Run? _run;
//Orchestrator
Orchestrator? _orchestrator;
OrchestratorThread? _orchestratorThread;
LiveObjSubscription? get _liveObj {
final sub = _subscription;
return sub is LiveObjSubscription ? sub : null;
}
void _logEvent(String message) {
debugPrint(message);
if (mounted) {
setState(() {
_log.add(
'[${DateTime.now().toIso8601String().substring(11, 19)}] $message');
});
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// 1. Connect
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _connect() async {
try {
_logEvent('connecting...');
final credentials = await _loadCredentials();
final passcode = await _fetchPasscode(credentials);
final updatedCredentials = credentials.copyWith(accessToken: passcode);
final adk = Adk(
adkConfig: AdkConfig(
uri: kServerUri,
authToken: passcode,
getCredentials: () => updatedCredentials,
),
);
adk.on('connection', (dynamic data) {
if (data is ConnectionDetail) {
_logEvent('connected · ${data.connectionId}');
} else {
_logEvent('connected · $data');
}
});
adk.on('close', (dynamic reason) => _logEvent('closed · $reason'));
await adk.connect();
setState(() => _adk = adk);
} catch (e) {
_logEvent('connect failed · $e');
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// 2. Subscribe to a channel (default, secure, or shared object)
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _subscribe() async {
final adk = _adk;
if (adk == null) {
_logEvent('connect first');
return;
}
try {
final sub = await adk.subscribe(channel: kChannel);
setState(() => _subscription = sub);
_logEvent('subscribed to $kChannel (${sub.channelConfig.channelType})');
// Bind a named event across any channel type.
sub.emitter.on('message', (dynamic data) {
_logEvent('message · $data');
});
// On default channels, also stream every event with listen().
if (sub is Subscription) {
sub.listen((Map<String, dynamic> data) {
_logEvent("event=${data['event']}");
});
}
// Track presence (must be enabled on the channel in the dashboard).
unawaited(sub.fetchPresence(
callback: (users) {
final normalized =
users.map((e) => e.split(':').first).toSet().toList();
if (!mounted) return;
setState(() {
_selectedUser ??= normalized.firstWhere(
(u) => u != kUsername,
orElse: () => '',
);
});
_logEvent('presence · $normalized');
},
)
// .catchError((e) {
// _logEvent('Presence ACK timeout: $e');
// }),
);
// On CRDT channels, observe the document tree.
if (sub is LiveObjSubscription) {
await sub.query(path: 'document').listen((dynamic data) {
_logEvent('doc · $data');
});
}
} catch (e) {
_logEvent('subscribe failed · $e');
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// 3. Push a message (optionally targeted)
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _sendMessage() async {
final sub = _subscription;
if (sub == null) {
_logEvent('Subscribe first');
return;
}
if (_selectedUser == null || _selectedUser!.isEmpty) {
_logEvent('No recipient available');
return;
}
final messageId = '$kUsername-${DateTime.now().millisecondsSinceEpoch}';
try {
final refId = await sub.push(
event: 'message',
data: {
'message': 'Hello from Flutter ADK',
'from': kUsername,
'id': messageId,
},
options: PushConfig(
to: ["USER_NAME"],
),
);
_logEvent('Message sent');
_logEvent('Reference Id: $refId');
} catch (e) {
_logEvent('Send failed: $e');
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// 4. Encryption — generate a keypair once per session
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _generateKeyPair() async {
final adk = _adk;
if (adk == null) {
_logEvent('connect first');
return;
}
try {
final pair = await adk.generateKeyPair();
_logEvent('keypair ready · pub=${pair.publicKey.substring(0, 10)}…');
} catch (e) {
_logEvent('keygen failed · $e');
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// 5. CRDT — set a document title
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _setDocTitle() async {
final live = _liveObj;
if (live == null) {
_logEvent('subscribe to a shared-object channel first');
return;
}
live.state()['document']['title'].set(
'Title @ ${DateTime.now().millisecondsSinceEpoch}',
);
await live.flush();
_logEvent('title written');
}
/// ───────────────────────────────────────────────────────────────────────────
/// 6. CRDT — array push / pop
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _pushToArray() async {
final live = _liveObj;
if (live == null) return;
final length = live.state()['items'].push('item ${DateTime.now()}');
await live.flush();
_logEvent('items · push (len=$length)');
}
Future<void> _popFromArray() async {
final live = _liveObj;
if (live == null) return;
final removed = live.state()['items'].pop();
await live.flush();
_logEvent('items · pop ($removed)');
}
/// ───────────────────────────────────────────────────────────────────────────
/// 7. Interceptor — log every message that passes through
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _addInterceptor() async {
final adk = _adk;
if (adk == null) return;
try {
await adk.intercept(
interceptor: 'demo-logger',
fn: (
Map<String, dynamic> payload,
void Function(dynamic data) resolve,
void Function(String error) reject,
) {
_logEvent('intercepted · ${payload['event']}');
resolve(payload);
},
);
_logEvent('interceptor installed');
} catch (e) {
_logEvent('interceptor failed · $e');
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// 8. Teardown
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _disconnect() async {
try {
await _subscription?.unsubscribe();
await _adk?.disconnect();
} finally {
if (mounted) {
setState(() {
_subscription = null;
_adk = null;
});
}
_logEvent('disconnected');
}
}
/// ───────────────────────────────────────────────────────────────────────────
/// Helpers
/// ───────────────────────────────────────────────────────────────────────────
Future<CredentialStore> _loadCredentials() async {
try {
final raw = await rootBundle.loadString('assets/adk-services.json');
final json = jsonDecode(raw) as Map<String, dynamic>;
return CredentialStore(
environment: json['Environment'] as String? ?? '',
projectKey: json['ProjectKey'] as String? ?? '',
orgTitle: json['Org-Title'] as String? ?? '',
clientID: json['Client-ID'] as String? ?? '',
clientSecret: json['Client-Secret'] as String? ?? '',
);
} catch (e) {
throw Exception('Failed to load assets/adk-services.json: $e');
}
}
Future<String> _fetchPasscode(CredentialStore creds) async {
final response = await http.post(
Uri.parse(kPasscodeEndpoint),
headers: <String, String>{
'Client-Id': creds.clientID,
'Client-Secret': creds.clientSecret,
'X-Org': creds.orgTitle,
'Environment': creds.environment,
'ProjectKey': creds.projectKey,
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'username': kUsername,
'first_name': 'Alice',
'last_name': 'Example',
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('passcode request failed (${response.statusCode})');
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final data = decoded['data'];
final passcode = data is Map<String, dynamic>
? data['passcode'] as String?
: decoded['passcode'] as String?;
if (passcode == null || passcode.isEmpty) {
throw Exception('passcode missing in response');
}
return passcode;
}
/// ───────────────────────────────────────────────────────────────────────────
/// Agent
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _createAgent() async {
final adk = _adk;
if (adk == null) {
_logEvent('connect first');
return;
}
try {
_agent = adk.agent('AGENT_ID');
_logEvent('Agent created');
} catch (e) {
_logEvent('create agent failed · $e');
}
}
Future<void> _startThread() async {
final agent = _agent;
if (agent == null) {
_logEvent('create agent first');
return;
}
try {
final thread = agent.thread();
_thread = thread;
_logEvent('Thread started');
_logEvent('Thread Id : ${thread.threadId}');
} catch (e) {
_logEvent('thread failed · $e');
}
}
Future<void> _listenAgent() async {
final thread = _thread;
if (thread == null) {
_logEvent('start thread first');
return;
}
await thread.listen((AgentEventEnvelope envelope) {
switch (envelope.event) {
case 'agent_general_response':
final output = envelope.content as AgentOutput;
_logEvent('Agent : ${output.message}');
break;
case 'agent_error_response':
final error = envelope.content as AgentError;
_logEvent('Error : ${error.message}');
break;
case 'human_input_request':
final request = envelope.content as HumanInputRequest;
_logEvent('Human Input : ${request.prompt}');
break;
case 'agent_wait_response':
final wait = envelope.content as AgentWait;
_logEvent('Waiting : ${wait.waitingForAgentId}');
break;
default:
_logEvent(envelope.event);
}
});
await thread.listenTrace((frame) {
_logEvent('TRACE : $frame');
});
thread.feedbackRequest(
(HumanInputRequest request, Run run) async {
_logEvent('Question : ${request.prompt}');
// Demo response
await run.sendFeedback(
'Budget 50,000 and travelling in December',
);
},
);
_logEvent('Agent listeners registered');
}
Future<void> _sendPrompt() async {
final thread = _thread;
if (thread == null) {
_logEvent('start thread first');
return;
}
try {
_run = await thread.run(
'Plan a 3-day trip to Goa',
);
final output = await _run!.done();
_logEvent('Final Response');
_logEvent(output.message);
} on AgentError catch (e) {
_logEvent('${e.code} : ${e.message}');
} catch (e) {
_logEvent(e.toString());
}
}
Future<void> _resetAgent() async {
_run = null;
_thread = null;
_agent = null;
_logEvent('Agent cleared');
}
/// ───────────────────────────────────────────────────────────────────────────
/// Orchestrator
/// ───────────────────────────────────────────────────────────────────────────
Future<void> _createOrchestrator() async {
final adk = _adk;
if (adk == null) {
_logEvent('connect first');
return;
}
try {
_orchestrator = adk.orchestrator("ORCHESTRATOR_ID");
_logEvent('Orchestrator created');
} catch (e) {
_logEvent('create orchestrator failed · $e');
}
}
Future<void> _startOrchestratorThread() async {
final orchestrator = _orchestrator;
if (orchestrator == null) {
_logEvent('create orchestrator first');
return;
}
try {
final thread = await orchestrator.thread();
_orchestratorThread = thread;
_logEvent('Orchestrator thread started');
_logEvent('Thread Id : ${thread.threadId}');
} catch (e) {
_logEvent('thread failed · $e');
}
}
Future<void> _listenOrchestrator() async {
final thread = _orchestratorThread;
if (thread == null) {
_logEvent('start orchestrator thread first');
return;
}
thread.listen((Map<String, dynamic> data) {
final event = data['event'] as String? ?? '';
final content = data['content'];
if (content is Map && content['reply'] is Function) {
_logEvent('Question : ${content['prompt']}');
final reply = content['reply'] as Function;
reply(<String, dynamic>{
'user_input': 'Budget 50,000 and travelling in December',
});
return;
}
final type = content is Map ? (content['type'] as String? ?? '') : '';
switch (type.isNotEmpty ? type : event) {
case 'agent_general_response':
_logEvent('Answer : ${content['message']}');
break;
case 'agent_error_response':
_logEvent(
'Error : ${content['message']}',
);
break;
case 'human_input_request':
_logEvent(
'Human Input : ${content['prompt']}',
);
break;
case 'agent_wait_response':
_logEvent(
'Waiting : ${content['waiting_for_agent_id']}',
);
break;
case 'planner_correction_request':
_logEvent(
'Planner : ${content['reason']}',
);
break;
default:
_logEvent('[$event] $content');
}
});
thread.listenTrace((frame) {
_logEvent('TRACE : $frame');
});
_logEvent('Orchestrator listeners registered');
}
Future<void> _runWorkflow() async {
final thread = _orchestratorThread;
if (thread == null) {
_logEvent('start orchestrator thread first');
return;
}
try {
await thread.push(
event: 'user_input',
data: <String, dynamic>{
'message': 'Plan a 3-day trip to Goa',
},
);
_logEvent('Workflow started');
} catch (e) {
_logEvent('Workflow failed · $e');
}
}
Future<void> _resetOrchestrator() async {
_orchestratorThread?.dispose();
_orchestratorThread = null;
_orchestrator = null;
_logEvent('Orchestrator cleared');
}
/// ───────────────────────────────────────────────────────────────────────────
/// UI
/// ───────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ART ADK Example'),
actions: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(
child: Text(
_adk?.getState() ?? 'stopped',
style: Theme.of(context).textTheme.labelMedium,
),
),
),
IconButton(
icon: Row(
children: [
Text("Clear Logs"),
const Icon(Icons.delete_outline),
],
),
onPressed: () => setState(() => _log.clear()),
tooltip: 'Clear Logs',
),
],
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
header('Connection'),
_btn('Connect', _connect, primary: true),
_btn('Go to Upload Tester', () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UploadTesterScreen(adk: _adk),
),
);
}, primary: true),
_btn('Subscribe', _subscribe),
_btn('Generate KeyPair', _generateKeyPair),
_btn('Disconnect', _disconnect, destructive: true),
const Divider(height: 1),
header('Communication'),
_btn('Send Message', _sendMessage),
const Divider(height: 1),
header('CRDT Operations'),
_btn('CRDT · Set Title', _setDocTitle),
_btn('CRDT · Array Push', _pushToArray),
_btn('CRDT · Array Pop', _popFromArray),
_btn('Add Interceptor', _addInterceptor),
const Divider(height: 1),
header('AI Agent'),
_btn(
'Create Agent',
_createAgent,
),
_btn(
'Start Thread',
_startThread,
),
_btn(
'Register Listeners',
_listenAgent,
),
_btn(
'Run Prompt',
_sendPrompt,
),
_btn('Reset Agent', _resetAgent, primary: true),
const Divider(height: 1),
header('Orchestrator'),
_btn(
'Create Orchestrator',
_createOrchestrator,
),
_btn(
'Start Orchestrator Thread',
_startOrchestratorThread,
),
_btn(
'Register Orchestrator Listeners',
_listenOrchestrator,
),
_btn(
'Run Workflow',
_runWorkflow,
),
_btn('Reset Orchestrator', _resetOrchestrator, primary: true),
],
),
),
),
);
}
Widget header(String text) =>
Align(alignment: Alignment.centerLeft, child: Text(text));
Widget _btn(
String label,
Future<void> Function() onTap, {
bool primary = false,
bool destructive = false,
}) {
final style = primary
? FilledButton.styleFrom()
: destructive
? FilledButton.styleFrom(
backgroundColor: Colors.red.shade400,
foregroundColor: Colors.white,
)
: null;
return primary || destructive
? FilledButton(style: style, onPressed: onTap, child: Text(label))
: OutlinedButton(onPressed: onTap, child: Text(label));
}
}
class UploadTesterScreen extends StatefulWidget {
final Adk? adk;
const UploadTesterScreen({super.key, this.adk});
@override
State<UploadTesterScreen> createState() => _UploadTesterScreenState();
}
class _UploadTesterScreenState extends State<UploadTesterScreen> {
bool _isConnected = false;
String _statusText = 'connecting…';
Color _statusColor = Colors.red;
final TextEditingController _configIdController =
TextEditingController(text: '6a730207ec6df8e15a4189cc');
PlatformFile? _selectedFile;
bool _isUploading = false;
double _uploadProgress = 0.0;
String? _uploadResult;
bool _isErrorResult = false;
String? _previewUrl;
List<StorageFile> _files = [];
bool _isLoadingFiles = false;
int _totalFiles = 0;
@override
void initState() {
super.initState();
_setupAdk();
}
@override
void dispose() {
_configIdController.dispose();
super.dispose();
}
void _setupAdk() {
final adk = widget.adk;
if (adk == null) {
setState(() {
_statusText = 'No ADK instance provided';
_statusColor = Colors.red;
});
return;
}
// Initial state
_updateStatusFromAdk();
adk.on('connection', (_) {
if (mounted) {
setState(() {
_isConnected = true;
_statusText = 'connected — ready to upload';
_statusColor = Colors.green;
});
}
});
adk.on('close', (_) {
if (mounted) {
setState(() {
_isConnected = false;
_statusText = 'disconnected';
_statusColor = Colors.red;
});
}
});
adk.on('error', (err) {
if (mounted) {
setState(() {
_statusText = 'error: $err';
_statusColor = Colors.red;
});
}
});
}
void _updateStatusFromAdk() {
final adk = widget.adk;
if (adk == null) return;
final state = adk.getState();
setState(() {
_isConnected = state == 'connected';
if (_isConnected) {
_statusText = 'connected — ready to upload';
_statusColor = Colors.green;
} else {
_statusText = state;
_statusColor = state == 'connecting' ? Colors.orange : Colors.red;
}
});
}
Future<void> _pickFile() async {
debugPrint('[Upload] Opening file picker...');
final result = await FilePicker.pickFiles(
withData: true, // Required for Web to get bytes
);
if (result != null) {
setState(() {
_selectedFile = result.files.first;
debugPrint('[Upload] Selected file: ${_selectedFile!.name} (size: ${_selectedFile!.size})');
});
} else {
debugPrint('[Upload] File picking cancelled');
}
}
Future<void> _upload() async {
debugPrint('[Upload] Starting _upload() function...');
final adk = widget.adk;
if (adk == null) {
debugPrint('[Upload] ERROR: ADK instance is null');
}
if (_selectedFile == null) {
debugPrint('[Upload] ERROR: No file selected');
}
if (!_isConnected) {
debugPrint('[Upload] ERROR: Not connected to ADK');
}
if (_selectedFile == null || !_isConnected || adk == null) {
debugPrint('[Upload] Aborting upload due to missing requirements');
return;
}
debugPrint('[Upload] Setting UI state to uploading...');
setState(() {
_isUploading = true;
_uploadProgress = 0.0;
_uploadResult = null;
_previewUrl = null;
});
try {
final Uint8List bytes;
if (kIsWeb) {
debugPrint('[Upload] Platform: Web. Reading bytes from memory...');
if (_selectedFile!.bytes == null) {
throw Exception('File data (bytes) is null. Make sure the file was picked correctly.');
}
bytes = _selectedFile!.bytes!;
} else {
debugPrint('[Upload] Platform: Native. Reading file from path: ${_selectedFile!.path}');
if (_selectedFile!.path == null) {
throw Exception('File path is null. Cannot read file on this platform.');
}
bytes = await File(_selectedFile!.path!).readAsBytes();
}
debugPrint('[Upload] Successfully read ${bytes.length} bytes');
final configId = _configIdController.text.trim();
final fileName = _selectedFile!.name;
String? contentType;
final ext = fileName.split('.').last.toLowerCase();
switch (ext) {
// Images
case 'jpg':
case 'jpeg':
contentType = 'image/jpeg';
break;
case 'png':
contentType = 'image/png';
break;
case 'gif':
contentType = 'image/gif';
break;
case 'webp':
contentType = 'image/webp';
break;
// Audio
case 'mp3':
contentType = 'audio/mpeg';
break;
case 'm4a':
contentType = 'audio/mp4';
break;
case 'wav':
contentType = 'audio/wav';
break;
case 'aac':
contentType = 'audio/aac';
break;
// Video
case 'mp4':
contentType = 'video/mp4';
break;
case 'mov':
contentType = 'video/quicktime';
break;
case 'avi':
contentType = 'video/x-msvideo';
break;
// Documents
case 'pdf':
contentType = 'application/pdf';
break;
case 'txt':
contentType = 'text/plain';
break;
case 'csv':
contentType = 'text/csv';
break;
case 'json':
contentType = 'application/json';
break;
case 'doc':
case 'docx':
contentType = 'application/msword';
break;
case 'xls':
case 'xlsx':
contentType = 'application/vnd.ms-excel';
break;
case 'ppt':
case 'pptx':
contentType = 'application/vnd.ms-powerpoint';
break;
// Archives
case 'zip':
contentType = 'application/zip';
break;
case 'rar':
contentType = 'application/x-rar-compressed';
break;
case '7z':
contentType = 'application/x-7z-compressed';
break;
default:
contentType = 'application/octet-stream';
}
debugPrint('[Upload] Using configId: "${configId.isEmpty ? 'default' : configId}"');
debugPrint('[Upload] Filename: "$fileName"');
debugPrint('[Upload] Inferred Content-Type: "$contentType"');
debugPrint('[Upload] Initiating adk.upload()...$contentType');
final ref = await adk.upload(
bytes,
opts: UploadOptions(
configId: configId.isEmpty ? null : configId,
filename: fileName,
contentType: contentType,
onProgress: (p) {
debugPrint('[Upload] Progress: ${(p * 100).toStringAsFixed(1)}%');
setState(() => _uploadProgress = p);
},
),
);
debugPrint('[Upload] Upload SUCCESS. Received FileRef:');
debugPrint(' - fileId: ${ref.fileId}');
debugPrint(' - name: ${ref.name}');
debugPrint(' - size: ${ref.size}');
debugPrint(' - contentType: ${ref.contentType}');
debugPrint(' - readUrl: ${ref.readUrl}');
setState(() {
_isErrorResult = false;
_uploadResult = 'OK\n'
'fileId: ${ref.fileId}\n'
'name: ${ref.name}\n'
'size: ${ref.size}\n'
'contentType: ${ref.contentType}\n'
'readUrl: ${ref.readUrl}';
if (ref.contentType.startsWith('image/')) {
debugPrint('[Upload] File is an image, setting preview URL');
_previewUrl = ref.readUrl;
}
});
debugPrint('[Upload] Refreshing file list...');
_refreshList();
} catch (e, stackTrace) {
debugPrint('[Upload] FATAL ERROR during upload: $e');
debugPrint('[Upload] Stacktrace: $stackTrace');
setState(() {
_isErrorResult = true;
_uploadResult = 'FAILED\n$e';
});
} finally {
debugPrint('[Upload] Function completed, setting _isUploading = false');
setState(() {
_isUploading = false;
});
}
}
Future<void> _refreshList() async {
final adk = widget.adk;
if (!_isConnected || adk == null) return;
setState(() {
_isLoadingFiles = true;
});
try {
final configId = _configIdController.text.trim();
final result = await adk.listFiles(
opts: ListOptions(
configId: configId.isEmpty ? null : configId,
),
);
setState(() {
_files = (result['files'] as List).cast<StorageFile>();
_totalFiles = result['total'] as int;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('List failed: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoadingFiles = false;
});
}
}
}
Future<void> _viewFile(StorageFile file) async {
final adk = widget.adk;
if (adk == null) return;
try {
final full = await adk.getFile(file.fileId);
if (full.readUrl != null) {
final url = Uri.parse(full.readUrl!);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not launch URL')),
);
}
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('View failed: $e')),
);
}
}
}
Future<void> _deleteFile(StorageFile file) async {
final adk = widget.adk;
if (adk == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete File'),
content: Text('Delete "${file.name}"?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')),
],
),
);
if (confirm == true) {
try {
await adk.deleteFile(file.fileId);
_refreshList();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Delete failed: $e')),
);
}
}
}
}
@override
Widget build(BuildContext context) {
const primaryColor = Color(0xFF075E54);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('ADK Upload Tester', style: TextStyle(color: Colors.white)),
backgroundColor: primaryColor,
iconTheme: const IconThemeData(color: Colors.white),
),
body: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
width: double.infinity,
color: const Color(0xFFF0F0F0),
child: Row(
children: [
const Text('Status: ', style: TextStyle(fontSize: 12)),
Text(
_statusText,
style: TextStyle(fontSize: 12, color: _statusColor, fontWeight: FontWeight.bold),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'config_id',
style: TextStyle(fontSize: 13, color: Color(0xFF333333)),
),
const SizedBox(height: 4),
TextField(
controller: _configIdController,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.all(8),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
const Text('Pick a file', style: TextStyle(fontSize: 13, color: Color(0xFF333333))),
const SizedBox(height: 4),
InkWell(
onTap: _pickFile,
child: Container(
padding: const EdgeInsets.all(8),
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_selectedFile?.name ?? 'No file selected',
style: TextStyle(color: _selectedFile == null ? Colors.grey : Colors.black),
),
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: (_isConnected && _selectedFile != null && !_isUploading) ? _upload : null,
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: const Text('Upload'),
),
),
if (_isUploading) ...[
const SizedBox(height: 14),
LinearProgressIndicator(
value: _uploadProgress,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(primaryColor),
minHeight: 14,
),
],
if (_uploadResult != null) ...[
const SizedBox(height: 14),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: _isErrorResult ? const Color(0xFFFDE2E2) : const Color(0xFFF7F7F7),
borderRadius: BorderRadius.circular(4),
),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 3,
color: _isErrorResult ? const Color(0xFFC0392B) : const Color(0xFFDDDDDD),
),
Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFDDDDDD)),
right: BorderSide(color: Color(0xFFDDDDDD)),
bottom: BorderSide(color: Color(0xFFDDDDDD)),
),
),
child: Text(
_uploadResult!,
style: TextStyle(
fontSize: 12,
color: _isErrorResult ? const Color(0xFF7A1C14) : Colors.black,
fontFamily: 'monospace',
),
),
),
),
],
),
),
),
],
if (_previewUrl != null) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(_previewUrl!),
),
],
const SizedBox(height: 14),
const Divider(color: Color(0xFFEEEEEE)),
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isConnected ? _refreshList : null,
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: const Text('List files in this config_id'),
),
),
const SizedBox(height: 10),
if (_isLoadingFiles)
const Center(child: Text('loading…', style: TextStyle(fontSize: 12, color: Colors.grey)))
else if (_files.isNotEmpty || _totalFiles > 0) ...[
Text('$_totalFiles file(s) in ${_configIdController.text.isEmpty ? "(project default)" : _configIdController.text}',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 6),
..._files.map((f) => _buildFileRow(f)),
] else
const Text('No files here yet', style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 14),
const Text.rich(
TextSpan(
children: [
TextSpan(text: 'Needs the ADK rebuilt with '),
TextSpan(text: 'adk.upload()', style: TextStyle(fontFamily: 'monospace', backgroundColor: Color(0xFFEEEEEE))),
TextSpan(text: ' and the '),
TextSpan(text: 'AdkUser', style: TextStyle(fontFamily: 'monospace', backgroundColor: Color(0xFFEEEEEE))),
TextSpan(text: ' storage RBAC grant. A '),
TextSpan(text: '403', style: TextStyle(fontFamily: 'monospace', backgroundColor: Color(0xFFEEEEEE))),
TextSpan(text: ' on '),
TextSpan(text: 'signed-url', style: TextStyle(fontFamily: 'monospace', backgroundColor: Color(0xFFEEEEEE))),
TextSpan(text: " means the grant isn't live yet."),
],
),
style: TextStyle(fontSize: 12, color: Color(0xFF666666)),
),
],
),
),
),
],
),
);
}
Widget _buildFileRow(StorageFile f) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF7F7F7),
border: Border.all(color: const Color(0xFFDDDDDD)),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Expanded(
child: Text(
f.name,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'${(f.size / 1024).round()} KB · ${f.status}',
style: const TextStyle(fontSize: 12, color: Color(0xFF888888)),
),
const SizedBox(width: 8),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: () => _viewFile(f),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
elevation: 0,
),
child: const Text('View', style: TextStyle(fontSize: 12)),
),
),
const SizedBox(width: 4),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: () => _deleteFile(f),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFC0392B),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
elevation: 0,
),
child: const Text('Delete', style: TextStyle(fontSize: 12)),
),
),
],
),
);
}
}