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 "../i18n.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) { // readAsBytes reads whatever the platform actually handed back — a path, a // content:// URI, a blob — so there is no longer a "picked a file but got no // bytes" case for the caller to guard. final bytes = await picked.readAsBytes(); 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(SnackBar(content: Text(t("errors.noFile")))); 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(t("errors.openFailed", params: {"error": res.message})))); } } catch (e) { messenger.showSnackBar( SnackBar(content: Text(t("errors.openFailed", params: {"error": 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; /// Blank means the shared wording (attachment.legend / attachment.hint); /// callers with a more specific one — "Receipt", "Photo or spec sheet" — /// pass it translated. 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 = "", this.hint = "", }); @override State createState() => _AttachmentFieldState(); } class _AttachmentFieldState extends State { Future _pick() async { final picked = await FilePicker.pickFile( type: FileType.custom, allowedExtensions: _allowedExtensions, ); if (picked == null) return; setState(() { widget.pending.file = picked; // 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.isEmpty ? t("attachment.legend") : widget.legend, border: const OutlineInputBorder(), helperText: widget.hint.isEmpty ? t("attachment.hint") : 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( t(hasExisting || picked != null ? "attachment.replace" : "attachment.choose")), ), 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: t("attachment.clear"), ), ], ), if (picked == null && hasExisting && !widget.pending.remove) Padding( padding: const EdgeInsets.only(top: 6), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ Text(t("attachment.attached", params: {"name": existing!.fileName}), style: theme.textTheme.bodySmall), if (widget.recordId != null) _LinkButton( label: t("attachment.view"), onPressed: () => openAttachment( context, widget.path, widget.recordId!, existing.fileName), ), _LinkButton( label: t("common.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(t("attachment.willBeRemoved"), style: theme.textTheme.bodySmall?.copyWith(color: muted)), _LinkButton(label: t("common.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, ), ), ); }