import "dart:io"; import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; import "package:open_filex/open_filex.dart"; import "package:path_provider/path_provider.dart"; import "../api.dart"; import "../main.dart"; import "../models.dart"; import "../theme.dart"; /// The extensions an attachment may carry, mirroring attachmentFileTypes in the /// API's attachments.go. The list is restrictive on purpose: these are scans and /// photos, and the server rejects anything else. const _allowedExtensions = ["pdf", "jpg", "jpeg", "png", "webp", "heic"]; /// A form's pending attachment change: a newly picked file, or a request to /// detach whatever is already there. Both empty means "leave it alone". class PendingAttachment { PlatformFile? file; bool remove = false; bool get isEmpty => file == null && !remove; } /// Applies a form's pending attachment change to the record it has just saved. /// /// This necessarily runs after the metadata write: the file endpoints address a /// record that must already exist. The order means a create-with-file is two /// calls, and the second one failing leaves a saved record with no attachment — /// which is why callers report it as an attachment error rather than a failed /// save, because the metadata is already committed. Future applyAttachment(String path, String id, PendingAttachment pending) async { final picked = pending.file; if (picked != null) { final bytes = picked.bytes ?? (picked.path != null ? await File(picked.path!).readAsBytes() : null); if (bytes == null) throw ApiException(0, "could not read the picked file"); await apiClient.uploadAttachment(path, id, bytes, picked.name); return; } if (pending.remove) await apiClient.deleteAttachment(path, id); } /// Downloads a record's attachment and hands it to the phone's viewer for that /// file type. The bytes are fetched through the API Server (never a public URL), /// then cached to a temp file because the OS viewers open paths, not buffers. Future openAttachment( BuildContext context, String path, String id, String fileName, ) async { final messenger = ScaffoldMessenger.of(context); try { final bytes = await apiClient.getAttachmentBytes(path, id); if (bytes == null) { messenger.showSnackBar(const SnackBar(content: Text("No file attached."))); return; } final dir = await getTemporaryDirectory(); final safe = fileName.isEmpty ? "attachment" : fileName.split(RegExp(r"[\\/]")).last; final f = File("${dir.path}/$safe"); await f.writeAsBytes(bytes); final res = await OpenFilex.open(f.path); if (res.type != ResultType.done) { messenger.showSnackBar(SnackBar(content: Text("Could not open: ${res.message}"))); } } catch (e) { messenger.showSnackBar(SnackBar(content: Text("Could not open attachment: $e"))); } } /// The attachment picker shared by every form that can carry a file. /// /// It only collects intent into [pending] — actually moving the bytes is the /// parent's job (see [applyAttachment]), because the endpoint addresses a record /// that must already exist. class AttachmentField extends StatefulWidget { /// The saved record, when editing; null while creating. Read for the name of /// whatever is already attached. final HasAttachment? record; /// The collection's API path (e.g. "/service-records"), used to fetch an /// existing attachment for viewing. final String path; /// The saved record's id; null while creating (nothing to view yet). final String? recordId; final PendingAttachment pending; final VoidCallback onChanged; final String legend; final String hint; const AttachmentField({ super.key, required this.path, required this.pending, required this.onChanged, this.record, this.recordId, this.legend = "Attachment", this.hint = "PDF or image, up to 10MB.", }); @override State createState() => _AttachmentFieldState(); } class _AttachmentFieldState extends State { Future _pick() async { final res = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: _allowedExtensions, withData: true, ); if (res == null || res.files.isEmpty) return; setState(() { widget.pending.file = res.files.first; // Picking a replacement supersedes a pending detach. widget.pending.remove = false; }); widget.onChanged(); } void _setRemove(bool v) { setState(() { widget.pending.remove = v; if (v) widget.pending.file = null; }); widget.onChanged(); } void _clearPick() { setState(() => widget.pending.file = null); widget.onChanged(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final muted = theme.textTheme.bodySmall?.color; final picked = widget.pending.file; final existing = widget.record; final hasExisting = existing?.hasFile == true; return InputDecorator( decoration: InputDecoration( labelText: widget.legend, border: const OutlineInputBorder(), helperText: widget.hint, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ OutlinedButton.icon( onPressed: _pick, icon: const Icon(Icons.attach_file, size: 18), label: Text(hasExisting || picked != null ? "Replace" : "Choose file"), ), const SizedBox(width: 8), if (picked != null) Expanded( child: Text( picked.name, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall, ), ), if (picked != null) IconButton( onPressed: _clearPick, icon: const Icon(Icons.close, size: 18), tooltip: "Clear", ), ], ), if (picked == null && hasExisting && !widget.pending.remove) Padding( padding: const EdgeInsets.only(top: 6), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ Text("Attached: ${existing!.fileName}", style: theme.textTheme.bodySmall), if (widget.recordId != null) _LinkButton( label: "View", onPressed: () => openAttachment( context, widget.path, widget.recordId!, existing.fileName), ), _LinkButton( label: "Remove", color: DriverVault.danger, onPressed: () => _setRemove(true), ), ], ), ), if (widget.pending.remove) Padding( padding: const EdgeInsets.only(top: 6), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ Text("Attachment will be removed on save.", style: theme.textTheme.bodySmall?.copyWith(color: muted)), _LinkButton(label: "Undo", onPressed: () => _setRemove(false)), ], ), ), ], ), ); } } class _LinkButton extends StatelessWidget { final String label; final Color? color; final VoidCallback onPressed; const _LinkButton({required this.label, required this.onPressed, this.color}); @override Widget build(BuildContext context) => InkWell( onTap: onPressed, child: Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: color ?? DriverVault.brandOnTint(context), fontWeight: FontWeight.w600, ), ), ); }