mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-08 23:18:16 -07:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
165
lib/screens/metadata/edit_item.dart
Normal file
165
lib/screens/metadata/edit_item.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/edit_item_provider.dart';
|
||||
import 'package:fladder/screens/metadata/edit_screens/edit_fields.dart';
|
||||
import 'package:fladder/screens/metadata/edit_screens/edit_image_content.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/refresh_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<ItemBaseModel?> showEditItemPopup(
|
||||
BuildContext context,
|
||||
String itemId,
|
||||
) async {
|
||||
ItemBaseModel? updatedItem;
|
||||
var shouldRefresh = false;
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
useSafeArea: false,
|
||||
builder: (context) {
|
||||
Widget editWidget() => EditDialogSwitcher(
|
||||
id: itemId,
|
||||
itemUpdated: (newItem) => updatedItem = newItem,
|
||||
refreshOnClose: (refresh) => shouldRefresh = refresh,
|
||||
);
|
||||
return AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
|
||||
? Dialog(
|
||||
insetPadding: EdgeInsets.all(64),
|
||||
child: editWidget(),
|
||||
)
|
||||
: Dialog.fullscreen(
|
||||
child: editWidget(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (shouldRefresh == true) {
|
||||
context.refreshData();
|
||||
}
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
class EditDialogSwitcher extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
final Function(ItemBaseModel? newItem) itemUpdated;
|
||||
final Function(bool refresh) refreshOnClose;
|
||||
|
||||
const EditDialogSwitcher({required this.id, required this.itemUpdated, required this.refreshOnClose, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EditDialogSwitcherState();
|
||||
}
|
||||
|
||||
class _EditDialogSwitcherState extends ConsumerState<EditDialogSwitcher> with TickerProviderStateMixin {
|
||||
late final TabController tabController = TabController(length: 5, vsync: this);
|
||||
|
||||
Future<void> refreshEditor() async {
|
||||
return ref.read(editItemProvider.notifier).fetchInformation(widget.id);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => refreshEditor());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentItem = ref.watch(editItemProvider.select((value) => value.item));
|
||||
final saving = ref.watch(editItemProvider.select((value) => value.saving));
|
||||
final state = ref.watch(editItemProvider).editedJson;
|
||||
final generalFields = ref.watch(editItemProvider.notifier).getFields ?? {};
|
||||
final advancedFields = ref.watch(editItemProvider.notifier).advancedFields ?? {};
|
||||
|
||||
Map<Tab, Widget> widgets = {
|
||||
Tab(text: "General"): EditFields(fields: generalFields, json: state),
|
||||
Tab(text: "Primary"): EditImageContent(type: ImageType.primary),
|
||||
Tab(text: "Logo"): EditImageContent(type: ImageType.logo),
|
||||
Tab(text: "Backdrops"): EditImageContent(type: ImageType.backdrop),
|
||||
Tab(text: "Advanced"): EditFields(fields: advancedFields, json: state),
|
||||
};
|
||||
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.paddingOf(context).top),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentItem?.detailedName(context) ?? currentItem?.name ?? "",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: () => refreshEditor(), icon: Icon(IconsaxOutline.refresh))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
tabs: widgets.keys.toList(),
|
||||
),
|
||||
),
|
||||
Flexible(child: TabBarView(controller: tabController, children: widgets.values.toList())),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.close)),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final response = await ref.read(editItemProvider.notifier).saveInformation();
|
||||
if (response != null && context.mounted) {
|
||||
if (response.isSuccessful) {
|
||||
widget.itemUpdated(response.body);
|
||||
fladderSnackbar(context,
|
||||
title: context.localized.metaDataSavedFor(
|
||||
currentItem?.detailedName(context) ?? currentItem?.name ?? ""));
|
||||
} else {
|
||||
fladderSnackbarResponse(context, response);
|
||||
}
|
||||
}
|
||||
widget.refreshOnClose(true);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: saving
|
||||
? SizedBox(
|
||||
width: 21,
|
||||
height: 21,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
backgroundColor: Theme.of(context).colorScheme.onPrimary, strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Text(context.localized.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
738
lib/screens/metadata/edit_screens/edit_fields.dart
Normal file
738
lib/screens/metadata/edit_screens/edit_fields.dart
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
|
||||
import 'package:fladder/models/items/item_shared_models.dart';
|
||||
import 'package:fladder/providers/edit_item_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/shared/focused_outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||
import 'package:fladder/screens/shared/outlined_text_field.dart';
|
||||
import 'package:fladder/util/jelly_id.dart';
|
||||
import 'package:fladder/util/list_extensions.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/adaptive_date_picker.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class EditFields extends ConsumerStatefulWidget {
|
||||
final Map<String, dynamic> fields;
|
||||
final Map<String, dynamic>? json;
|
||||
const EditFields({
|
||||
required this.fields,
|
||||
required this.json,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EditGeneralState();
|
||||
}
|
||||
|
||||
class _EditGeneralState extends ConsumerState<EditFields> {
|
||||
TextEditingController? currentController = TextEditingController();
|
||||
String? currentEditingKey;
|
||||
List<String> expandedKeys = [];
|
||||
|
||||
final personName = TextEditingController();
|
||||
PersonKind personType = PersonKind.actor;
|
||||
final personRole = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (widget.json != null)
|
||||
...widget.fields.entries.map(
|
||||
(e) {
|
||||
final keyLabel = e.key.toUpperCaseSplit();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: switch (e.value) {
|
||||
Map<String, bool> _ => Builder(builder: (context) {
|
||||
final map = e.value as Map<String, bool>;
|
||||
return SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: EnumBox(
|
||||
current: map.entries.firstWhereOrNull((element) => element.value == true)?.key ?? "",
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Text(""),
|
||||
onTap: () => ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, "")),
|
||||
),
|
||||
...map.entries.map(
|
||||
(mapEntry) => PopupMenuItem(
|
||||
child: Text(mapEntry.key),
|
||||
onTap: () => ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, mapEntry.key)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
List<String> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
final list = e.value as List<String>;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...{
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(genre) => Row(
|
||||
children: [
|
||||
Text(genre.toString()),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, list..remove(genre)),
|
||||
),
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
OutlinedTextField(
|
||||
label: "Add",
|
||||
controller: TextEditingController(),
|
||||
onSubmitted: (value) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, list..add(value)),
|
||||
);
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
List<Person> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
final list = e.value as List<Person>;
|
||||
|
||||
List<Map<String, dynamic>> listToMap(List<Person> people) {
|
||||
return people.map((e) => e.toPerson().toJson()).toList();
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...{
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(person) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
child: Center(
|
||||
child: Text(
|
||||
person.name.getInitials(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(person.name),
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(person.role.isNotEmpty
|
||||
? "${person.role} (${person.type}) "
|
||||
: person.type?.value ?? ""),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, listToMap(list..remove(person))));
|
||||
},
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedTextField(
|
||||
label: "Name",
|
||||
controller: personName,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
child: EnumBox<PersonKind>(
|
||||
current: personType.name.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => [
|
||||
...PersonKind.values
|
||||
.whereNot(
|
||||
(element) => element == PersonKind.swaggerGeneratedUnknown)
|
||||
.map(
|
||||
(entry) => PopupMenuItem(
|
||||
child: Text(entry.name.toUpperCaseSplit()),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
personType = entry;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(MapEntry(
|
||||
e.key,
|
||||
listToMap(list
|
||||
..add(
|
||||
Person(
|
||||
id: jellyId,
|
||||
name: personName.text,
|
||||
type: personType,
|
||||
role: personRole.text,
|
||||
),
|
||||
))));
|
||||
setState(() {
|
||||
personName.text = "";
|
||||
personType = PersonKind.actor;
|
||||
personRole.text = "";
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.add_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
List<ExternalUrls> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
final list = e.value as List<ExternalUrls>;
|
||||
final name = TextEditingController();
|
||||
final url = TextEditingController();
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...{
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(externalUrl) => Row(
|
||||
children: [
|
||||
Text(externalUrl.name),
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
message: "Open in browser",
|
||||
child: IconButton(
|
||||
onPressed: () => launchUrl(context, externalUrl.url),
|
||||
icon: Icon(Icons.open_in_browser_rounded)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(
|
||||
e.key,
|
||||
(list..remove(externalUrl))
|
||||
.map((e) => e.toMap())
|
||||
.toList()),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: "Name",
|
||||
controller: name,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
child: OutlinedTextField(
|
||||
label: "Url",
|
||||
controller: url,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(
|
||||
e.key,
|
||||
(list
|
||||
..add(
|
||||
ExternalUrls(name: name.text, url: url.text),
|
||||
))
|
||||
.map((e) => e.toMap())
|
||||
.toList()),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.add_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
List<Studio> _ => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 21),
|
||||
child: Builder(builder: (context) {
|
||||
final expanded = expandedKeys.contains(e.key);
|
||||
|
||||
final list = e.value as List<Studio>;
|
||||
|
||||
void setMapping(List<Studio> newList) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newList.map((e) => e.toMap()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
keyLabel,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
icon: Icon(expanded
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded),
|
||||
)
|
||||
],
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(studio) => Row(
|
||||
children: [
|
||||
Text(studio.name),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => setMapping(list..remove(studio)),
|
||||
icon: Icon(Icons.remove_rounded))
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
OutlinedTextField(
|
||||
label: "Add",
|
||||
controller: TextEditingController(),
|
||||
onSubmitted: (value) =>
|
||||
setMapping(list..add(Studio(id: jellyId, name: value))),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
int value => Builder(builder: (context) {
|
||||
final controller = currentEditingKey == e.key
|
||||
? currentController
|
||||
: TextEditingController(text: value.toString());
|
||||
return FocusedOutlinedTextField(
|
||||
label: switch (e.key) {
|
||||
"IndexNumber" => "Episode Number",
|
||||
"ParentIndexNumber" => "Season Number",
|
||||
_ => keyLabel,
|
||||
},
|
||||
controller: controller,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentController = controller;
|
||||
currentEditingKey = e.key;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newYear),
|
||||
);
|
||||
}
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
if (currentEditingKey != e.key) {
|
||||
currentEditingKey = e.key;
|
||||
currentController = controller;
|
||||
}
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newYear),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
double value => Builder(builder: (context) {
|
||||
final controller = currentEditingKey == e.key
|
||||
? currentController
|
||||
: TextEditingController(text: value.toString());
|
||||
return FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
controller: controller,
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentController = controller;
|
||||
currentEditingKey = e.key;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
onSubmitted: (newValue) {
|
||||
final newRating = double.tryParse(newValue);
|
||||
if (newRating != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, newRating),
|
||||
);
|
||||
} else {
|
||||
controller?.text = value.toString();
|
||||
}
|
||||
currentController = null;
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
);
|
||||
}),
|
||||
DateTime _ => Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
onTap: () async {
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
final newDate = await showAdaptiveDatePicker(
|
||||
context,
|
||||
initialDateTime: e.value,
|
||||
);
|
||||
if (newDate == null) return;
|
||||
ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, newDate.toIso8601String()));
|
||||
},
|
||||
controller:
|
||||
TextEditingController(text: DateFormat.yMMMEd().format((e.value as DateTime))),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final newDate = await showDatePicker(
|
||||
context: context,
|
||||
currentDate: DateTime.now(),
|
||||
initialDate: e.value,
|
||||
firstDate: DateTime(1950),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (newDate == null) return;
|
||||
ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, newDate.toIso8601String()));
|
||||
},
|
||||
icon: Icon(IconsaxOutline.calendar_2))
|
||||
],
|
||||
),
|
||||
DisplayOrder _ => Builder(builder: (context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: EnumBox(
|
||||
current: (e.value as DisplayOrder).value.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => DisplayOrder.values
|
||||
.map(
|
||||
(mapEntry) => PopupMenuItem(
|
||||
child: Text(mapEntry.value.toUpperCaseSplit()),
|
||||
onTap: () => ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, mapEntry.value)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text("Order episodes by air date, DVD order, or absolute numbering."),
|
||||
)
|
||||
],
|
||||
);
|
||||
}),
|
||||
ShowStatus _ => Builder(builder: (context) {
|
||||
return SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: EnumBox(
|
||||
current: (e.value as ShowStatus).value,
|
||||
itemBuilder: (context) => ShowStatus.values
|
||||
.map(
|
||||
(mapEntry) => PopupMenuItem(
|
||||
child: Text(mapEntry.value),
|
||||
onTap: () => ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, mapEntry.value)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
bool _ => SettingsListTile(
|
||||
label: Text(keyLabel),
|
||||
trailing: Switch.adaptive(
|
||||
value: e.value as bool,
|
||||
onChanged: (value) =>
|
||||
ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, value)),
|
||||
),
|
||||
),
|
||||
Duration _ => Builder(builder: (context) {
|
||||
final valueInMinutes = (e.value as Duration).inMinutes.toString();
|
||||
final controller = currentEditingKey == e.key
|
||||
? currentController
|
||||
: TextEditingController(text: valueInMinutes);
|
||||
return FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
controller: controller,
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentController = controller;
|
||||
currentEditingKey = e.key;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (value) {
|
||||
final newMinutes = int.tryParse(value);
|
||||
if (newMinutes != null) {
|
||||
ref.read(editItemProvider.notifier).updateField(
|
||||
MapEntry(e.key, Duration(minutes: newMinutes).inMilliseconds * 10000),
|
||||
);
|
||||
} else {
|
||||
controller?.text = valueInMinutes;
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
Map<EditorLockedFields, bool> _ => Builder(builder: (context) {
|
||||
final map = e.value as Map<EditorLockedFields, bool>;
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => expandedKeys = expandedKeys.toggle(e.key)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(keyLabel, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"Uncheck a field to lock it and prevent its data from being changed.",
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Column(
|
||||
children: map.entries
|
||||
.map((values) => Row(
|
||||
children: [
|
||||
Text(values.key.value),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: !values.value,
|
||||
onChanged: (value) {
|
||||
final newEntries = map;
|
||||
newEntries.update(values.key, (value) => !value);
|
||||
final newValues = newEntries.entries
|
||||
.where((element) => element.value == true)
|
||||
.map((e) => e.key.value);
|
||||
ref
|
||||
.read(editItemProvider.notifier)
|
||||
.updateField(MapEntry(e.key, newValues.toList()));
|
||||
},
|
||||
)
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
String value => Builder(builder: (context) {
|
||||
final controller =
|
||||
currentEditingKey == e.key ? currentController : TextEditingController(text: value);
|
||||
return FocusedOutlinedTextField(
|
||||
label: keyLabel,
|
||||
maxLines: e.key == "Overview" ? 5 : 1,
|
||||
controller: controller,
|
||||
onFocus: (focused) {
|
||||
if (focused) {
|
||||
currentEditingKey = e.key;
|
||||
currentController = controller;
|
||||
} else {
|
||||
currentController = null;
|
||||
currentEditingKey = null;
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) =>
|
||||
ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, value)),
|
||||
onChanged: (value) {
|
||||
if (currentEditingKey != e.key) {
|
||||
currentEditingKey = e.key;
|
||||
currentController = controller;
|
||||
}
|
||||
return ref.read(editItemProvider.notifier).updateField(MapEntry(e.key, value));
|
||||
},
|
||||
);
|
||||
}),
|
||||
_ => Text("Not supported ${e.value.runtimeType}: ${e.value}"),
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/screens/metadata/edit_screens/edit_image_content.dart
Normal file
243
lib/screens/metadata/edit_screens/edit_image_content.dart
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
|
||||
import 'package:fladder/models/item_editing_model.dart';
|
||||
import 'package:fladder/providers/edit_item_provider.dart';
|
||||
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/shared/file_picker.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class EditImageContent extends ConsumerStatefulWidget {
|
||||
final ImageType type;
|
||||
const EditImageContent({required this.type, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _EditImageContentState();
|
||||
}
|
||||
|
||||
class _EditImageContentState extends ConsumerState<EditImageContent> {
|
||||
bool loading = false;
|
||||
|
||||
Future<void> loadAll() async {
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
await ref.read(editItemProvider.notifier).fetchRemoteImages(type: widget.type);
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => loadAll());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final posterSize = MediaQuery.sizeOf(context).width /
|
||||
(AdaptiveLayout.poster(context).gridRatio *
|
||||
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
|
||||
final decimal = posterSize - posterSize.toInt();
|
||||
final includeAllImages = ref.watch(editItemProvider.select((value) => value.includeAllImages));
|
||||
final images = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.backdrop => value.backdrop.images,
|
||||
ImageType.logo => value.logo.images,
|
||||
ImageType.primary || _ => value.primary.images,
|
||||
}));
|
||||
|
||||
final customImages = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.backdrop => value.backdrop.customImages,
|
||||
ImageType.logo => value.logo.customImages,
|
||||
ImageType.primary || _ => value.primary.customImages,
|
||||
}));
|
||||
|
||||
final selectedImage = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.logo => value.logo.selected,
|
||||
ImageType.primary => value.primary.selected,
|
||||
_ => null,
|
||||
}));
|
||||
|
||||
final serverImages = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.logo => value.logo.serverImages,
|
||||
ImageType.primary => value.primary.serverImages,
|
||||
ImageType.backdrop => value.backdrop.serverImages,
|
||||
_ => null,
|
||||
}));
|
||||
|
||||
final selections = ref.watch(editItemProvider.select((value) => switch (widget.type) {
|
||||
ImageType.backdrop => value.backdrop.selection,
|
||||
_ => [],
|
||||
}));
|
||||
|
||||
final serverImageCards = serverImages?.map((image) {
|
||||
final selected = selectedImage == null;
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: image.ratio,
|
||||
child: Tooltip(
|
||||
message: "Server image",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Theme.of(context).colorScheme.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border:
|
||||
Border.all(color: Colors.transparent, width: 4, strokeAlign: BorderSide.strokeAlignInside),
|
||||
),
|
||||
child: Card(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
child: InkWell(
|
||||
onTap: () => ref.read(editItemProvider.notifier).selectImage(widget.type, null),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: image.hashCode.toString(),
|
||||
imageUrl: image.url ?? "",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Transform.translate(
|
||||
offset: Offset(2, 2),
|
||||
child: IconButton.filledTonal(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text("Delete image"),
|
||||
content: Text("Deleting is permanent are you sure?"),
|
||||
actions: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text("Cancel")),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () async {
|
||||
await ref.read(editItemProvider.notifier).deleteImage(widget.type, image);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
"Delete",
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.delete_rounded),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}) ??
|
||||
[];
|
||||
|
||||
final imageCards = [...customImages, ...images].map((image) {
|
||||
final selected = switch (widget.type) {
|
||||
ImageType.backdrop => selections.contains(image),
|
||||
_ => selectedImage == image,
|
||||
};
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: image.ratio,
|
||||
child: Tooltip(
|
||||
message: "${image.providerName} - ${image.language} \n${image.width}x${image.height}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: selected ? Theme.of(context).colorScheme.primary : Colors.transparent,
|
||||
width: 4,
|
||||
strokeAlign: BorderSide.strokeAlignInside),
|
||||
),
|
||||
child: Card(
|
||||
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
child: InkWell(
|
||||
onTap: () => ref.read(editItemProvider.notifier).selectImage(widget.type, image),
|
||||
child: image.imageData != null
|
||||
? Image(image: Image.memory(image.imageData!).image)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: image.url ?? "",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList();
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: FilePickerBar(
|
||||
multipleFiles: switch (widget.type) {
|
||||
ImageType.backdrop => true,
|
||||
_ => false,
|
||||
},
|
||||
extensions: FladderFile.imageTypes,
|
||||
urlPicked: (url) {
|
||||
final newFile = EditingImageModel(providerName: "Custom(URL)", url: url);
|
||||
ref.read(editItemProvider.notifier).addCustomImages(widget.type, [newFile]);
|
||||
},
|
||||
onFilesPicked: (file) {
|
||||
final newFiles = file.map(
|
||||
(e) => EditingImageModel(
|
||||
providerName: "Custom(${e.name})",
|
||||
imageData: e.data,
|
||||
),
|
||||
);
|
||||
ref.read(editItemProvider.notifier).addCustomImages(widget.type, newFiles);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text("Include all languages"),
|
||||
trailing: Switch.adaptive(
|
||||
value: includeAllImages,
|
||||
onChanged: (value) {
|
||||
ref.read(editItemProvider.notifier).setIncludeImages(value);
|
||||
loadAll();
|
||||
},
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Stack(
|
||||
children: [
|
||||
GridView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.vertical,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
mainAxisSpacing: (8 * decimal) + 8,
|
||||
crossAxisSpacing: (8 * decimal) + 8,
|
||||
childAspectRatio: 1.0,
|
||||
crossAxisCount: posterSize.toInt(),
|
||||
),
|
||||
children: [...serverImageCards, ...imageCards],
|
||||
),
|
||||
if (loading) Center(child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round)),
|
||||
if (!loading && [...serverImageCards, ...imageCards].isEmpty)
|
||||
Center(child: Text("No ${widget.type.value}s found"))
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
361
lib/screens/metadata/identifty_screen.dart
Normal file
361
lib/screens/metadata/identifty_screen.dart
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/identify_provider.dart';
|
||||
import 'package:fladder/screens/shared/adaptive_dialog.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/screens/shared/focused_outlined_text_field.dart';
|
||||
import 'package:fladder/screens/shared/media/external_urls.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showIdentifyScreen(BuildContext context, ItemBaseModel item) async {
|
||||
return showDialogAdaptive(
|
||||
context: context,
|
||||
builder: (context) => IdentifyScreen(
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class IdentifyScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const IdentifyScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _IdentifyScreenState();
|
||||
}
|
||||
|
||||
class _IdentifyScreenState extends ConsumerState<IdentifyScreen> with TickerProviderStateMixin {
|
||||
late AutoDisposeStateNotifierProvider<IdentifyNotifier, IdentifyModel> provider = identifyProvider(widget.item.id);
|
||||
late final TabController tabController = TabController(length: 2, vsync: this);
|
||||
|
||||
TextEditingController? currentController;
|
||||
String? currentKey;
|
||||
int currentTab = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(provider.notifier).fetchInformation());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(provider);
|
||||
final posters = state.results;
|
||||
final processing = state.processing;
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.paddingOf(context).top),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.item.detailedName(context) ?? widget.item.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () async => await ref.read(provider.notifier).fetchInformation(),
|
||||
icon: Icon(IconsaxOutline.refresh)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
onTap: (value) {
|
||||
setState(() {
|
||||
currentTab = value;
|
||||
});
|
||||
},
|
||||
tabs: [
|
||||
Tab(
|
||||
text: context.localized.search,
|
||||
),
|
||||
Tab(
|
||||
text: context.localized.result,
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
inputFields(state),
|
||||
if (posters.isEmpty)
|
||||
Center(
|
||||
child: processing
|
||||
? CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round)
|
||||
: Text(context.localized.noResults),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(context.localized.replaceAllImages),
|
||||
const SizedBox(width: 16),
|
||||
Switch.adaptive(
|
||||
value: state.replaceAllImages,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(provider.notifier)
|
||||
.update((state) => state.copyWith(replaceAllImages: value));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: posters
|
||||
.map((result) => ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 75,
|
||||
child: Card(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: result.imageUrl ?? "",
|
||||
errorWidget: (context, url, error) => SizedBox(
|
||||
height: 75,
|
||||
child: Card(
|
||||
child: Center(
|
||||
child: Text(result.name?.getInitials() ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"${result.name ?? ""}${result.productionYear != null ? "(${result.productionYear})" : ""}"),
|
||||
Opacity(
|
||||
opacity: 0.65,
|
||||
child: Text(result.providerIds?.keys.join(',') ?? ""))
|
||||
],
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: context.localized.openWebLink,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
final providerKeyEntry = result.providerIds?.entries.first;
|
||||
final providerKey = providerKeyEntry?.key;
|
||||
final providerValue = providerKeyEntry?.value;
|
||||
|
||||
final externalId = state.externalIds
|
||||
.firstWhereOrNull((element) => element.key == providerKey)
|
||||
?.urlFormatString;
|
||||
|
||||
final url =
|
||||
externalId?.replaceAll("{0}", providerValue?.toString() ?? "");
|
||||
|
||||
launchUrl(context, url ?? "");
|
||||
},
|
||||
icon: Icon(Icons.launch_rounded)),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Select result",
|
||||
child: IconButton(
|
||||
onPressed: !processing
|
||||
? () async {
|
||||
final response =
|
||||
await ref.read(provider.notifier).setIdentity(result);
|
||||
if (response?.isSuccessful == true) {
|
||||
fladderSnackbar(context,
|
||||
title:
|
||||
context.localized.setIdentityTo(result.name ?? ""));
|
||||
} else {
|
||||
fladderSnackbarResponse(context, response,
|
||||
altTitle: context.localized.somethingWentWrong);
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
: null,
|
||||
icon: Icon(Icons.save_alt_rounded),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.cancel)),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: !processing
|
||||
? () async {
|
||||
await ref.read(provider.notifier).remoteSearch();
|
||||
tabController.animateTo(1);
|
||||
}
|
||||
: null,
|
||||
child: processing
|
||||
? SizedBox(
|
||||
width: 21,
|
||||
height: 21,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
backgroundColor: Theme.of(context).colorScheme.onPrimary, strokeCap: StrokeCap.round),
|
||||
)
|
||||
: Text(context.localized.search),
|
||||
),
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ListView inputFields(IdentifyModel state) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
currentController = null;
|
||||
currentKey = "";
|
||||
ref.read(provider.notifier).clearFields();
|
||||
},
|
||||
child: Text(context.localized.clear)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Builder(builder: (context) {
|
||||
final controller =
|
||||
currentKey == "Name" ? currentController : TextEditingController(text: state.searchString);
|
||||
return FocusedOutlinedTextField(
|
||||
label: context.localized.userName,
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
currentController = controller;
|
||||
currentKey = "Name";
|
||||
return ref.read(provider.notifier).update((state) => state.copyWith(searchString: value));
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
return ref.read(provider.notifier).update((state) => state.copyWith(searchString: value));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Builder(builder: (context) {
|
||||
final controller =
|
||||
currentKey == "Year" ? currentController : TextEditingController(text: state.year?.toString() ?? "");
|
||||
return FocusedOutlinedTextField(
|
||||
label: context.localized.year(1),
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
currentController = controller;
|
||||
currentKey = "Year";
|
||||
if (value.isEmpty) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => null,
|
||||
));
|
||||
return;
|
||||
}
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => newYear,
|
||||
));
|
||||
} else {
|
||||
controller?.text = state.year?.toString() ?? "";
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
currentController = null;
|
||||
currentKey = null;
|
||||
if (value.isEmpty) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => null,
|
||||
));
|
||||
}
|
||||
final newYear = int.tryParse(value);
|
||||
if (newYear != null) {
|
||||
ref.read(provider.notifier).update((state) => state.copyWith(
|
||||
year: () => newYear,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
...state.keys.entries.map(
|
||||
(searchKey) => Builder(builder: (context) {
|
||||
final controller =
|
||||
currentKey == searchKey.key ? currentController : TextEditingController(text: searchKey.value);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: FocusedOutlinedTextField(
|
||||
label: searchKey.key,
|
||||
controller: controller,
|
||||
onChanged: (value) {
|
||||
currentController = controller;
|
||||
currentKey = searchKey.key;
|
||||
ref.read(provider.notifier).updateKey(MapEntry(searchKey.key, value));
|
||||
},
|
||||
onSubmitted: (value) => ref.read(provider.notifier).updateKey(MapEntry(searchKey.key, value)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
217
lib/screens/metadata/info_screen.dart
Normal file
217
lib/screens/metadata/info_screen.dart
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/information_model.dart';
|
||||
import 'package:fladder/models/item_base_model.dart';
|
||||
import 'package:fladder/providers/items/information_provider.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/clickable_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<void> showInfoScreen(BuildContext context, ItemBaseModel item) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => ItemInfoScreen(
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ItemInfoScreen extends ConsumerStatefulWidget {
|
||||
final ItemBaseModel item;
|
||||
const ItemInfoScreen({required this.item, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => ItemInfoScreenState();
|
||||
}
|
||||
|
||||
class ItemInfoScreenState extends ConsumerState<ItemInfoScreen> {
|
||||
late AutoDisposeStateNotifierProvider<InformationNotifier, InformationProviderModel> provider =
|
||||
informationProvider(widget.item.id);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(provider.notifier).getItemInformation(widget.item));
|
||||
}
|
||||
|
||||
Widget tileRow(String title, String value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
text: title,
|
||||
onTap: () async {
|
||||
await Clipboard.setData(ClipboardData(text: value));
|
||||
fladderSnackbar(context, title: "Copied to clipboard");
|
||||
},
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
": ",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: SelectableText(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Card streamModel(String title, Map<String, dynamic> map) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClickableText(
|
||||
text: title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: InformationModel.mapToString(map)));
|
||||
fladderSnackbar(context, title: "Copied to clipboard");
|
||||
},
|
||||
icon: const Icon(Icons.copy_all_rounded))
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
...map.entries
|
||||
.where((element) => element.value != null)
|
||||
.map((mapEntry) => tileRow(mapEntry.key, mapEntry.value.toString()))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final info = ref.watch(provider);
|
||||
final videoStreams = (info.model?.videoStreams.map((map) => streamModel("Video", map)) ?? []).toList();
|
||||
final audioStreams = (info.model?.audioStreams.map((map) => streamModel("Audio", map)) ?? []).toList();
|
||||
final subStreams = (info.model?.subStreams.map((map) => streamModel("Subtitle", map)) ?? []).toList();
|
||||
return Dialog(
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
widget.item.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Opacity(opacity: 0.3, child: const Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const Spacer(),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: info.model.toString()));
|
||||
if (context.mounted) {
|
||||
fladderSnackbar(context, title: "Copied to clipboard");
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy_all_rounded)),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(provider.notifier).getItemInformation(widget.item),
|
||||
icon: const Icon(IconsaxOutline.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (info.model != null) ...{
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: double.infinity, child: streamModel("Info", info.model!.baseInformation)),
|
||||
if ([...videoStreams, ...audioStreams, ...subStreams].isNotEmpty) ...{
|
||||
const Divider(),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.start,
|
||||
runAlignment: WrapAlignment.start,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
runSpacing: 16,
|
||||
spacing: 16,
|
||||
children: [
|
||||
...videoStreams,
|
||||
...audioStreams,
|
||||
...subStreams,
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
AnimatedOpacity(
|
||||
opacity: info.loading ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: const Center(child: CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round)),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.localized.close))
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/screens/metadata/refresh_metadata.dart
Normal file
123
lib/screens/metadata/refresh_metadata.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:fladder/jellyfin/enum_models.dart';
|
||||
import 'package:fladder/providers/user_provider.dart';
|
||||
import 'package:fladder/screens/settings/settings_list_tile.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/localization_helper.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showRefreshPopup(BuildContext context, String itemId, String itemName) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => RefreshPopupDialog(
|
||||
itemId: itemId,
|
||||
name: itemName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class RefreshPopupDialog extends ConsumerStatefulWidget {
|
||||
final String itemId;
|
||||
final String name;
|
||||
|
||||
const RefreshPopupDialog({required this.itemId, required this.name, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _RefreshPopupDialogState();
|
||||
}
|
||||
|
||||
class _RefreshPopupDialogState extends ConsumerState<RefreshPopupDialog> {
|
||||
MetadataRefresh refreshMode = MetadataRefresh.defaultRefresh;
|
||||
bool replaceAllMetadata = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 700 : double.infinity),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
context.localized.refreshPopup(widget.name),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EnumBox(
|
||||
current: refreshMode.label(context),
|
||||
itemBuilder: (context) => MetadataRefresh.values
|
||||
.map((value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.label(context)),
|
||||
onTap: () => setState(() {
|
||||
refreshMode = value;
|
||||
}),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
if (refreshMode != MetadataRefresh.defaultRefresh)
|
||||
SettingsListTile(
|
||||
label: Text(context.localized.replaceExistingImages),
|
||||
trailing: Switch.adaptive(
|
||||
value: replaceAllMetadata,
|
||||
onChanged: (value) => setState(() => replaceAllMetadata = value),
|
||||
),
|
||||
),
|
||||
SettingsListTile(
|
||||
label: Text(
|
||||
context.localized.refreshPopupContentMetadata,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final response = await ref.read(userProvider.notifier).refreshMetaData(
|
||||
widget.itemId,
|
||||
metadataRefreshMode: refreshMode,
|
||||
replaceAllMetadata: replaceAllMetadata,
|
||||
);
|
||||
if (!response.isSuccessful) {
|
||||
fladderSnackbarResponse(context, response);
|
||||
} else {
|
||||
fladderSnackbar(context, title: context.localized.scanningName(widget.name));
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(context.localized.refresh)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue