304 lines
No EOL
11 KiB
Dart
304 lines
No EOL
11 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:path/path.dart';
|
|
|
|
class MessageAttachmentList extends StatefulWidget {
|
|
const MessageAttachmentList({required this.onChange, required this.disabled, this.initialFiles, super.key});
|
|
|
|
final List<(FileType, File)>? initialFiles;
|
|
final Function(List<(FileType, File)> files) onChange;
|
|
final bool disabled;
|
|
|
|
@override
|
|
State<MessageAttachmentList> createState() => _MessageAttachmentListState();
|
|
}
|
|
|
|
class _MessageAttachmentListState extends State<MessageAttachmentList> {
|
|
final List<(FileType, File)> _loadedFiles = [];
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _showShadow = true;
|
|
bool _popupIsOpen = false;
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadedFiles.clear();
|
|
_loadedFiles.addAll(widget.initialFiles ?? []);
|
|
_scrollController.addListener(() {
|
|
if (_scrollController.position.maxScrollExtent > 0 && !_showShadow) {
|
|
setState(() {
|
|
_showShadow = true;
|
|
});
|
|
}
|
|
if (_scrollController.position.atEdge && _scrollController.position.pixels > 0
|
|
&& _showShadow) {
|
|
setState(() {
|
|
_showShadow = false;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
Expanded(
|
|
child: ShaderMask(
|
|
shaderCallback: (Rect bounds) {
|
|
return LinearGradient(
|
|
begin: Alignment.centerLeft,
|
|
end: Alignment.centerRight,
|
|
colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.surfaceVariant
|
|
],
|
|
stops: [0.0, 0.0, _showShadow ? 0.90 : 1.0, 1.0], // 10% purple, 80% transparent, 10% purple
|
|
).createShader(bounds);
|
|
},
|
|
blendMode: BlendMode.dstOut,
|
|
child: SingleChildScrollView(
|
|
controller: _scrollController,
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: _loadedFiles.map((file) =>
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4.0, right: 4.0, top: 4.0),
|
|
child: TextButton.icon(
|
|
onPressed: widget.disabled ? null : () {
|
|
showDialog(context: context, builder: (context) =>
|
|
AlertDialog(
|
|
title: const Text("Remove attachment"),
|
|
content: Text(
|
|
"This will remove attachment '${basename(
|
|
file.$2.path)}', are you sure?"),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: const Text("No"),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
setState(() {
|
|
_loadedFiles.remove(file);
|
|
});
|
|
await widget.onChange(_loadedFiles);
|
|
},
|
|
child: const Text("Yes"),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
},
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.onSurfaceVariant,
|
|
side: BorderSide(
|
|
color: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.primary,
|
|
width: 1
|
|
),
|
|
),
|
|
label: Text(basename(file.$2.path)),
|
|
icon: switch (file.$1) {
|
|
FileType.image => const Icon(Icons.image),
|
|
_ => const Icon(Icons.attach_file)
|
|
}
|
|
),
|
|
),
|
|
).toList()
|
|
),
|
|
),
|
|
),
|
|
),
|
|
AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 200),
|
|
switchInCurve: Curves.decelerate,
|
|
transitionBuilder: (child, animation) => FadeTransition(
|
|
opacity: animation,
|
|
child: SizeTransition(
|
|
sizeFactor: animation,
|
|
axis: Axis.horizontal,
|
|
//position: Tween<Offset>(begin: const Offset(1, 0), end: const Offset(0, 0)).animate(animation),
|
|
child: child,
|
|
),
|
|
),
|
|
child: _popupIsOpen ? Row(
|
|
key: const ValueKey("popup-buttons"),
|
|
children: [
|
|
IconButton(
|
|
iconSize: 24,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.surface,
|
|
foregroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.onSurface,
|
|
side: BorderSide(
|
|
width: 1,
|
|
color: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.secondary,
|
|
)
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
onPressed: () async {
|
|
final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
|
|
if (result != null) {
|
|
setState(() {
|
|
_loadedFiles.addAll(
|
|
result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null)
|
|
.whereNotNull());
|
|
});
|
|
}
|
|
|
|
},
|
|
icon: const Icon(Icons.image,),
|
|
),
|
|
IconButton(
|
|
iconSize: 24,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.surface,
|
|
foregroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.onSurface,
|
|
side: BorderSide(
|
|
width: 1,
|
|
color: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.secondary,
|
|
)
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
onPressed: () async {
|
|
final picture = await ImagePicker().pickImage(source: ImageSource.camera);
|
|
if (picture != null) {
|
|
final file = File(picture.path);
|
|
if (await file.exists()) {
|
|
setState(() {
|
|
_loadedFiles.add((FileType.image, file));
|
|
});
|
|
await widget.onChange(_loadedFiles);
|
|
} else {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file")));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
icon: const Icon(Icons.camera,),
|
|
),
|
|
IconButton(
|
|
iconSize: 24,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.surface,
|
|
foregroundColor: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.onSurface,
|
|
side: BorderSide(
|
|
width: 1,
|
|
color: Theme
|
|
.of(context)
|
|
.colorScheme
|
|
.secondary,
|
|
)
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
onPressed: () async {
|
|
final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true);
|
|
if (result != null) {
|
|
setState(() {
|
|
_loadedFiles.addAll(
|
|
result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null)
|
|
.whereNotNull());
|
|
});
|
|
}
|
|
},
|
|
icon: const Icon(Icons.file_present_rounded,),
|
|
),
|
|
],
|
|
) : const SizedBox.shrink(),
|
|
),
|
|
Container(
|
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
|
child: IconButton(onPressed: () {
|
|
setState(() {
|
|
_popupIsOpen = !_popupIsOpen;
|
|
});
|
|
}, icon: AnimatedRotation(
|
|
duration: const Duration(milliseconds: 200),
|
|
turns: _popupIsOpen ? 3/8 : 0,
|
|
child: const Icon(Icons.add),
|
|
)),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
enum DocumentType {
|
|
gallery,
|
|
camera,
|
|
rawFile;
|
|
}
|
|
|
|
class PopupMenuIcon<T> extends PopupMenuEntry<T> {
|
|
const PopupMenuIcon({this.radius=24, this.value, required this.icon, this.onPressed, super.key});
|
|
|
|
final T? value;
|
|
final double radius;
|
|
final Widget icon;
|
|
final void Function()? onPressed;
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _PopupMenuIconState();
|
|
|
|
@override
|
|
double get height => radius;
|
|
|
|
@override
|
|
bool represents(T? value) => this.value == value;
|
|
|
|
}
|
|
|
|
class _PopupMenuIconState extends State<PopupMenuIcon> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(128),
|
|
child: Container(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
|
|
margin: const EdgeInsets.all(1),
|
|
child: InkWell(
|
|
child: widget.icon,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |