533 lines
20 KiB
Dart
533 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:cross_file/cross_file.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:gap/gap.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:island/models/file.dart';
|
|
import 'package:island/pods/network.dart';
|
|
import 'package:island/services/file.dart';
|
|
import 'package:island/widgets/alert.dart';
|
|
import 'package:island/widgets/content/cloud_files.dart';
|
|
import 'package:island/widgets/content/sheet.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:styled_widget/styled_widget.dart';
|
|
import 'package:super_context_menu/super_context_menu.dart';
|
|
|
|
import 'sensitive.dart';
|
|
|
|
class SensitiveMarksSelector extends StatefulWidget {
|
|
final List<int> initial;
|
|
final ValueChanged<List<int>>? onChanged;
|
|
|
|
const SensitiveMarksSelector({
|
|
super.key,
|
|
required this.initial,
|
|
this.onChanged,
|
|
});
|
|
|
|
@override
|
|
State<SensitiveMarksSelector> createState() => SensitiveMarksSelectorState();
|
|
}
|
|
|
|
class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
|
|
late List<int> _selected;
|
|
|
|
List<int> get current => _selected;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selected = [...widget.initial];
|
|
}
|
|
|
|
void _toggle(int value) {
|
|
setState(() {
|
|
if (_selected.contains(value)) {
|
|
_selected.remove(value);
|
|
} else {
|
|
_selected.add(value);
|
|
}
|
|
});
|
|
widget.onChanged?.call([..._selected]);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Build a list of all categories in fixed order as int list indices
|
|
final categories = kSensitiveCategoriesOrdered;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
for (var i = 0; i < categories.length; i++)
|
|
FilterChip(
|
|
label: Text(categories[i].i18nKey.tr()),
|
|
avatar: Text(categories[i].symbol),
|
|
selected: _selected.contains(i),
|
|
onSelected: (_) => _toggle(i),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class AttachmentPreview extends HookConsumerWidget {
|
|
final UniversalFile item;
|
|
final double? progress;
|
|
final Function(int)? onMove;
|
|
final Function? onDelete;
|
|
final Function? onInsert;
|
|
final Function(UniversalFile)? onUpdate;
|
|
final Function? onRequestUpload;
|
|
|
|
const AttachmentPreview({
|
|
super.key,
|
|
required this.item,
|
|
this.progress,
|
|
this.onRequestUpload,
|
|
this.onMove,
|
|
this.onDelete,
|
|
this.onUpdate,
|
|
this.onInsert,
|
|
});
|
|
|
|
// GlobalKey for selector
|
|
static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
|
|
GlobalKey<SensitiveMarksSelectorState>();
|
|
|
|
Future<void> _showRenameDialog(BuildContext context, WidgetRef ref) async {
|
|
final nameController = TextEditingController(text: item.data.name);
|
|
String? errorMessage;
|
|
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder:
|
|
(context) => SheetScaffold(
|
|
heightFactor: 0.6,
|
|
titleText: 'rename'.tr(),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 24,
|
|
),
|
|
child: TextField(
|
|
controller: nameController,
|
|
decoration: InputDecoration(
|
|
labelText: 'fileName'.tr(),
|
|
border: const OutlineInputBorder(),
|
|
errorText: errorMessage,
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('cancel'.tr()),
|
|
),
|
|
const Gap(8),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final newName = nameController.text.trim();
|
|
if (newName.isEmpty) {
|
|
errorMessage = 'fieldCannotBeEmpty'.tr();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showLoadingModal(context);
|
|
final apiClient = ref.watch(apiClientProvider);
|
|
await apiClient.patch(
|
|
'/drive/files/${item.data.id}/name',
|
|
data: jsonEncode(newName),
|
|
);
|
|
final newData = item.data;
|
|
newData.name = newName;
|
|
final updatedFile = item.copyWith(data: newData);
|
|
onUpdate?.call(item.copyWith(data: updatedFile));
|
|
if (context.mounted) Navigator.pop(context);
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
} finally {
|
|
if (context.mounted) hideLoadingModal(context);
|
|
}
|
|
},
|
|
child: Text('rename'.tr()),
|
|
),
|
|
],
|
|
).padding(horizontal: 16, vertical: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showSensitiveDialog(BuildContext context, WidgetRef ref) async {
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder:
|
|
(context) => SheetScaffold(
|
|
heightFactor: 0.6,
|
|
titleText: 'markAsSensitive'.tr(),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 24,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Sensitive categories checklist
|
|
SensitiveMarksSelector(
|
|
key: _sensitiveSelectorKey,
|
|
initial:
|
|
(item.data.sensitiveMarks ?? [])
|
|
.map((e) => e as int)
|
|
.cast<int>()
|
|
.toList(),
|
|
onChanged: (marks) {
|
|
// Update local data immediately (optimistic)
|
|
final newData = item.data;
|
|
newData.sensitiveMarks = marks;
|
|
final updatedFile = item.copyWith(data: newData);
|
|
onUpdate?.call(item.copyWith(data: updatedFile));
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('cancel'.tr()),
|
|
),
|
|
const Gap(8),
|
|
TextButton(
|
|
onPressed: () async {
|
|
try {
|
|
showLoadingModal(context);
|
|
final apiClient = ref.watch(apiClientProvider);
|
|
// Use the current selections from stateful selector via GlobalKey
|
|
final selectorState =
|
|
_sensitiveSelectorKey.currentState;
|
|
final marks = selectorState?.current ?? <int>[];
|
|
await apiClient.put(
|
|
'/drive/files/${item.data.id}/marks',
|
|
data: jsonEncode({'sensitive_marks': marks}),
|
|
);
|
|
final newData = item.data as SnCloudFile;
|
|
final updatedFile = item.copyWith(
|
|
data: newData.copyWith(sensitiveMarks: marks),
|
|
);
|
|
onUpdate?.call(updatedFile);
|
|
if (context.mounted) Navigator.pop(context);
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
} finally {
|
|
if (context.mounted) hideLoadingModal(context);
|
|
}
|
|
},
|
|
child: Text('confirm'.tr()),
|
|
),
|
|
],
|
|
).padding(horizontal: 16, vertical: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
var ratio =
|
|
item.isOnCloud
|
|
? (item.data.fileMeta?['ratio'] is num
|
|
? item.data.fileMeta!['ratio'].toDouble()
|
|
: 1.0)
|
|
: 1.0;
|
|
if (ratio == 0) ratio = 1.0;
|
|
|
|
final contentWidget = ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
color: Colors.black.withOpacity(0.5),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (onDelete != null)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Icon(
|
|
item.isLink ? Symbols.link_off : Symbols.delete,
|
|
size: 14,
|
|
color: Colors.white,
|
|
).padding(horizontal: 8, vertical: 6),
|
|
onTap: () {
|
|
onDelete?.call();
|
|
},
|
|
),
|
|
if (onDelete != null && onMove != null)
|
|
SizedBox(
|
|
height: 26,
|
|
child: const VerticalDivider(
|
|
width: 0.3,
|
|
color: Colors.white,
|
|
thickness: 0.3,
|
|
),
|
|
).padding(horizontal: 2),
|
|
if (onMove != null)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: const Icon(
|
|
Symbols.keyboard_arrow_up,
|
|
size: 14,
|
|
color: Colors.white,
|
|
).padding(horizontal: 8, vertical: 6),
|
|
onTap: () {
|
|
onMove?.call(-1);
|
|
},
|
|
),
|
|
if (onMove != null)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: const Icon(
|
|
Symbols.keyboard_arrow_down,
|
|
size: 14,
|
|
color: Colors.white,
|
|
).padding(horizontal: 8, vertical: 6),
|
|
onTap: () {
|
|
onMove?.call(1);
|
|
},
|
|
),
|
|
if (onInsert != null)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: const Icon(
|
|
Symbols.add,
|
|
size: 14,
|
|
color: Colors.white,
|
|
).padding(horizontal: 8, vertical: 6),
|
|
onTap: () {
|
|
onInsert?.call();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (onRequestUpload != null)
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () => onRequestUpload?.call(),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
color: Colors.black.withOpacity(0.5),
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
child:
|
|
(item.isOnCloud)
|
|
? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Symbols.cloud,
|
|
size: 16,
|
|
color: Colors.white,
|
|
),
|
|
const Gap(8),
|
|
Text(
|
|
'On-cloud',
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
],
|
|
)
|
|
: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Symbols.cloud_off,
|
|
size: 16,
|
|
color: Colors.white,
|
|
),
|
|
const Gap(8),
|
|
Text(
|
|
'On-device',
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
).padding(horizontal: 12, vertical: 8),
|
|
AspectRatio(
|
|
aspectRatio: ratio,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Builder(
|
|
key: ValueKey(item.hashCode),
|
|
builder: (context) {
|
|
if (item.isOnCloud) {
|
|
return CloudFileWidget(item: item.data);
|
|
} else if (item.data is XFile) {
|
|
final file = item.data as XFile;
|
|
if (file.path.isEmpty) {
|
|
return FutureBuilder<Uint8List>(
|
|
future: file.readAsBytes(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasData) {
|
|
return Image.memory(snapshot.data!);
|
|
}
|
|
return const Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
switch (item.type) {
|
|
case UniversalFileType.image:
|
|
return kIsWeb
|
|
? Image.network(file.path)
|
|
: Image.file(File(file.path));
|
|
default:
|
|
return Column(
|
|
children: [
|
|
const Icon(Symbols.document_scanner),
|
|
Text(file.name),
|
|
],
|
|
);
|
|
}
|
|
} else if (item is List<int> || item is Uint8List) {
|
|
switch (item.type) {
|
|
case UniversalFileType.image:
|
|
return Image.memory(item.data);
|
|
default:
|
|
return Column(
|
|
children: [const Icon(Symbols.document_scanner)],
|
|
);
|
|
}
|
|
}
|
|
return Placeholder();
|
|
},
|
|
),
|
|
if (progress != null)
|
|
Positioned.fill(
|
|
child: Container(
|
|
color: Colors.black.withOpacity(0.3),
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 40,
|
|
vertical: 16,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (progress != null)
|
|
Text(
|
|
'${progress!.toStringAsFixed(2)}%',
|
|
style: TextStyle(color: Colors.white),
|
|
)
|
|
else
|
|
Text(
|
|
'uploading'.tr(),
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
Gap(6),
|
|
Center(
|
|
child: LinearProgressIndicator(
|
|
value:
|
|
progress != null ? progress! / 100.0 : null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
return ContextMenuWidget(
|
|
menuProvider:
|
|
(MenuRequest request) => Menu(
|
|
children: [
|
|
if (item.isOnDevice && item.type == UniversalFileType.image)
|
|
MenuAction(
|
|
title: 'crop'.tr(),
|
|
image: MenuImage.icon(Symbols.crop),
|
|
callback: () async {
|
|
final result = await cropImage(
|
|
context,
|
|
image: item.data,
|
|
replacePath: true,
|
|
);
|
|
if (result == null) return;
|
|
onUpdate?.call(item.copyWith(data: result));
|
|
},
|
|
),
|
|
if (item.isOnCloud)
|
|
MenuAction(
|
|
title: 'rename'.tr(),
|
|
image: MenuImage.icon(Symbols.edit),
|
|
callback: () async {
|
|
await _showRenameDialog(context, ref);
|
|
},
|
|
),
|
|
if (item.isOnCloud)
|
|
MenuAction(
|
|
title: 'markAsSensitive'.tr(),
|
|
image: MenuImage.icon(Symbols.no_adult_content),
|
|
callback: () async {
|
|
await _showSensitiveDialog(context, ref);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
child: contentWidget,
|
|
);
|
|
}
|
|
}
|