Init repo

This commit is contained in:
PartyDonut 2024-09-15 14:12:28 +02:00
commit 764b6034e3
566 changed files with 212335 additions and 0 deletions

View file

@ -0,0 +1,476 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/build_routes/route_builder.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart';
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
import 'package:fladder/screens/shared/default_alert_dialog.dart';
import 'package:fladder/screens/shared/input_fields.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/custom_color_themes.dart';
import 'package:fladder/util/local_extension.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/option_dialogue.dart';
import 'package:fladder/util/simple_duration_picker.dart';
import 'package:fladder/util/size_formatting.dart';
import 'package:fladder/util/theme_mode_extension.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ClientSettingsPage extends ConsumerStatefulWidget {
const ClientSettingsPage({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ClientSettingsPageState();
}
class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
late final nextUpDaysEditor = TextEditingController(
text: ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff?.inDays ?? 14)).toString());
late final libraryPageSizeController = TextEditingController(
text: ref.read(clientSettingsProvider.select((value) => value.libraryPageSize))?.toString() ?? "");
@override
Widget build(BuildContext context) {
final clientSettings = ref.watch(clientSettingsProvider);
final showBackground = AdaptiveLayout.of(context).layout != LayoutState.phone &&
AdaptiveLayout.of(context).size != ScreenLayout.single;
final currentFolder = ref.watch(syncProvider.notifier).savePath;
Locale currentLocale = WidgetsBinding.instance.platformDispatcher.locale;
final canSync = ref.watch(userProvider.select((value) => value?.canDownload ?? false));
return Card(
elevation: showBackground ? 2 : 0,
child: SettingsScaffold(
label: "Fladder",
items: [
if (canSync && !kIsWeb) ...[
SettingsLabelDivider(label: context.localized.downloadsTitle),
if (AdaptiveLayout.of(context).isDesktop) ...[
SettingsListTile(
label: Text(context.localized.downloadsPath),
subLabel: Text(currentFolder ?? "-"),
onTap: currentFolder != null
? () async => await showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(context.localized.pathEditTitle),
content: Text(context.localized.pathEditDesc),
actions: [
ElevatedButton(
onPressed: () async {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.localized.pathEditSelect, initialDirectory: currentFolder);
if (selectedDirectory != null) {
ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory);
}
Navigator.of(context).pop();
},
child: Text(context.localized.change),
)
],
),
)
: () async {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.localized.pathEditSelect, initialDirectory: currentFolder);
if (selectedDirectory != null) {
ref.read(clientSettingsProvider.notifier).setSyncPath(selectedDirectory);
}
},
trailing: currentFolder?.isNotEmpty == true
? IconButton(
color: Theme.of(context).colorScheme.error,
onPressed: () async => await showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(context.localized.pathClearTitle),
content: Text(context.localized.pathEditDesc),
actions: [
ElevatedButton(
onPressed: () {
ref.read(clientSettingsProvider.notifier).setSyncPath(null);
Navigator.of(context).pop();
},
child: Text(context.localized.clear),
)
],
),
),
icon: Icon(IconsaxOutline.folder_minus),
)
: null,
),
],
FutureBuilder(
future: ref.watch(syncProvider.notifier).directorySize,
builder: (context, snapshot) {
final data = snapshot.data ?? 0;
return SettingsListTile(
label: Text(context.localized.downloadsSyncedData),
subLabel: Text(data.byteFormat ?? ""),
trailing: FilledButton(
onPressed: () {
showDefaultAlertDialog(
context,
context.localized.downloadsClearTitle,
context.localized.downloadsClearDesc,
(context) async {
await ref.read(syncProvider.notifier).clear();
setState(() {});
context.pop();
},
context.localized.clear,
(context) => context.pop(),
context.localized.cancel,
);
},
child: Text(context.localized.clear),
),
);
},
),
const Divider(),
],
SettingsLabelDivider(label: context.localized.lockscreen),
SettingsListTile(
label: Text(context.localized.timeOut),
subLabel: Text(timePickerString(context, clientSettings.timeOut)),
onTap: () async {
final timePicker = await showSimpleDurationPicker(
context: context,
initialValue: clientSettings.timeOut ?? const Duration(),
);
ref.read(clientSettingsProvider.notifier).setTimeOut(timePicker != null
? Duration(minutes: timePicker.inMinutes, seconds: timePicker.inSeconds % 60)
: null);
},
),
const Divider(),
SettingsLabelDivider(label: context.localized.dashboard),
SettingsListTile(
label: Text(context.localized.settingsHomeCarouselTitle),
subLabel: Text(context.localized.settingsHomeCarouselDesc),
trailing: EnumBox(
current: ref.watch(
homeSettingsProvider.select(
(value) => value.carouselSettings.label(context),
),
),
itemBuilder: (context) => HomeCarouselSettings.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref
.read(homeSettingsProvider.notifier)
.update((context) => context.copyWith(carouselSettings: entry)),
),
)
.toList(),
),
),
SettingsListTile(
label: Text(context.localized.settingsHomeNextUpTitle),
subLabel: Text(context.localized.settingsHomeNextUpDesc),
trailing: EnumBox(
current: ref.watch(
homeSettingsProvider.select(
(value) => value.nextUp.label(context),
),
),
itemBuilder: (context) => HomeNextUp.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () =>
ref.read(homeSettingsProvider.notifier).update((context) => context.copyWith(nextUp: entry)),
),
)
.toList(),
),
),
const Divider(),
SettingsLabelDivider(label: context.localized.settingsVisual),
SettingsListTile(
label: Text(context.localized.displayLanguage),
trailing: EnumBox(
current: ref.watch(
clientSettingsProvider.select(
(value) => (value.selectedLocale ?? currentLocale).label(),
),
),
itemBuilder: (context) {
return [
...AppLocalizations.supportedLocales.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(
entry.label(),
style: TextStyle(
fontWeight: currentLocale.languageCode == entry.languageCode ? FontWeight.bold : null,
),
),
onTap: () => ref
.read(clientSettingsProvider.notifier)
.update((state) => state.copyWith(selectedLocale: entry)),
),
)
];
},
),
),
SettingsListTile(
label: Text(context.localized.settingsBlurredPlaceholderTitle),
subLabel: Text(context.localized.settingsBlurredPlaceholderDesc),
onTap: () =>
ref.read(clientSettingsProvider.notifier).setBlurPlaceholders(!clientSettings.blurPlaceHolders),
trailing: Switch(
value: clientSettings.blurPlaceHolders,
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setBlurPlaceholders(value),
),
),
SettingsListTile(
label: Text(context.localized.settingsBlurEpisodesTitle),
subLabel: Text(context.localized.settingsBlurEpisodesDesc),
onTap: () =>
ref.read(clientSettingsProvider.notifier).setBlurEpisodes(!clientSettings.blurUpcomingEpisodes),
trailing: Switch(
value: clientSettings.blurUpcomingEpisodes,
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setBlurEpisodes(value),
),
),
SettingsListTile(
label: Text(context.localized.settingsEnableOsMediaControls),
onTap: () => ref.read(clientSettingsProvider.notifier).setMediaKeys(!clientSettings.enableMediaKeys),
trailing: Switch(
value: clientSettings.enableMediaKeys,
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setMediaKeys(value),
),
),
SettingsListTile(
label: Text(context.localized.settingsNextUpCutoffDays),
trailing: SizedBox(
width: 100,
child: IntInputField(
suffix: context.localized.days,
controller: nextUpDaysEditor,
onSubmitted: (value) {
if (value != null) {
ref.read(clientSettingsProvider.notifier).update((current) => current.copyWith(
nextUpDateCutoff: Duration(days: value),
));
}
},
)),
),
SettingsListTile(
label: Text(context.localized.libraryPageSizeTitle),
subLabel: Text(context.localized.libraryPageSizeDesc),
trailing: SizedBox(
width: 100,
child: IntInputField(
controller: libraryPageSizeController,
placeHolder: "500",
onSubmitted: (value) => ref.read(clientSettingsProvider.notifier).update(
(current) => current.copyWith(libraryPageSize: value),
),
)),
),
SettingsListTile(
label: Text(AdaptiveLayout.of(context).isDesktop
? context.localized.settingsShowScaleSlider
: context.localized.settingsPosterPinch),
onTap: () => ref.read(clientSettingsProvider.notifier).update(
(current) => current.copyWith(pinchPosterZoom: !current.pinchPosterZoom),
),
trailing: Switch(
value: clientSettings.pinchPosterZoom,
onChanged: (value) => ref.read(clientSettingsProvider.notifier).update(
(current) => current.copyWith(pinchPosterZoom: value),
),
),
),
Column(
children: [
SettingsListTile(
label: Text(context.localized.settingsPosterSize),
trailing: Text(
clientSettings.posterSize.toString(),
style: Theme.of(context).textTheme.bodyLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FladderSlider(
min: 0.5,
max: 1.5,
value: clientSettings.posterSize,
divisions: 20,
onChanged: (value) => ref
.read(clientSettingsProvider.notifier)
.update((current) => current.copyWith(posterSize: value)),
),
),
const Divider(),
],
),
SettingsLabelDivider(label: context.localized.theme),
SettingsListTile(
label: Text(context.localized.mode),
subLabel: Text(clientSettings.themeMode.label(context)),
onTap: () => openOptionDialogue(
context,
label: "${context.localized.theme} ${context.localized.mode}",
items: ThemeMode.values,
itemBuilder: (type) => RadioListTile(
value: type,
title: Text(type?.label(context) ?? context.localized.other),
contentPadding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
groupValue: ref.read(clientSettingsProvider.select((value) => value.themeMode)),
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setThemeMode(value),
),
),
),
SettingsListTile(
label: Text(context.localized.color),
subLabel: Text(clientSettings.themeColor?.name ?? context.localized.dynamicText),
onTap: () => openOptionDialogue<ColorThemes>(
context,
isNullable: !kIsWeb,
label: context.localized.themeColor,
items: ColorThemes.values,
itemBuilder: (type) => Consumer(
builder: (context, ref, child) => ListTile(
title: Row(
children: [
Checkbox(
value: type == ref.watch(clientSettingsProvider.select((value) => value.themeColor)),
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setThemeColor(type),
),
const SizedBox(width: 4),
Container(
height: 24,
width: 24,
decoration: BoxDecoration(
gradient: type == null
? const SweepGradient(
center: FractionalOffset.center,
colors: <Color>[
Color(0xFF4285F4), // blue
Color(0xFF34A853), // green
Color(0xFFFBBC05), // yellow
Color(0xFFEA4335), // red
Color(0xFF4285F4), // blue again to seamlessly transition to the start
],
stops: <double>[0.0, 0.25, 0.5, 0.75, 1.0],
)
: null,
color: type?.color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Text(type?.name ?? context.localized.dynamicText),
],
),
contentPadding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
onTap: () => ref.read(clientSettingsProvider.notifier).setThemeColor(type),
),
),
),
),
SettingsListTile(
label: Text(context.localized.amoledBlack),
subLabel: Text(clientSettings.amoledBlack ? context.localized.enabled : context.localized.disabled),
onTap: () => ref.read(clientSettingsProvider.notifier).setAmoledBlack(!clientSettings.amoledBlack),
trailing: Switch(
value: clientSettings.amoledBlack,
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setAmoledBlack(value),
),
),
if (AdaptiveLayout.of(context).isDesktop) ...[
const Divider(),
SettingsLabelDivider(label: context.localized.controls),
SettingsListTile(
label: Text(context.localized.mouseDragSupport),
subLabel: Text(clientSettings.mouseDragSupport ? context.localized.enabled : context.localized.disabled),
onTap: () => ref
.read(clientSettingsProvider.notifier)
.update((current) => current.copyWith(mouseDragSupport: !clientSettings.mouseDragSupport)),
trailing: Switch(
value: clientSettings.mouseDragSupport,
onChanged: (value) => ref.read(clientSettingsProvider.notifier).setAmoledBlack(value),
),
),
],
const SizedBox(height: 64),
SettingsListTile(
label: Text(
context.localized.clearAllSettings,
),
contentColor: Theme.of(context).colorScheme.error,
onTap: () {
showDialog(
context: context,
builder: (context) => Dialog(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.localized.clearAllSettingsQuestion,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Text(
context.localized.unableToReverseAction,
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => context.pop(),
child: Text(context.localized.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
await ref.read(sharedPreferencesProvider).clear();
context.routeGo(LoginRoute());
},
child: Text(context.localized.clear),
)
],
),
],
),
),
),
);
},
),
const SizedBox(height: 16),
],
),
);
}
}

View file

@ -0,0 +1,123 @@
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart';
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
import 'package:fladder/screens/settings/widgets/settings_message_box.dart';
import 'package:fladder/screens/settings/widgets/subtitle_editor.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/box_fit_extension.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/option_dialogue.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io' show Platform;
class PlayerSettingsPage extends ConsumerStatefulWidget {
const PlayerSettingsPage({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _PlayerSettingsPageState();
}
class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
@override
Widget build(BuildContext context) {
final videoSettings = ref.watch(videoPlayerSettingsProvider);
final provider = ref.read(videoPlayerSettingsProvider.notifier);
final showBackground = AdaptiveLayout.of(context).layout != LayoutState.phone &&
AdaptiveLayout.of(context).size != ScreenLayout.single;
return Card(
elevation: showBackground ? 2 : 0,
child: SettingsScaffold(
label: context.localized.settingsPlayerTitle,
items: [
SettingsLabelDivider(label: context.localized.video),
if (!AdaptiveLayout.of(context).isDesktop && !kIsWeb)
SettingsListTile(
label: Text(context.localized.videoScalingFillScreenTitle),
subLabel: Text(context.localized.videoScalingFillScreenDesc),
onTap: () => provider.setFillScreen(!videoSettings.fillScreen),
trailing: Switch(
value: videoSettings.fillScreen,
onChanged: (value) => provider.setFillScreen(value),
),
),
AnimatedFadeSize(
child: videoSettings.fillScreen
? SettingsMessageBox(
context.localized.videoScalingFillScreenNotif,
messageType: MessageType.warning,
)
: Container(),
),
SettingsListTile(
label: Text(context.localized.videoScalingFillScreenTitle),
subLabel: Text(videoSettings.videoFit.label(context)),
onTap: () => openOptionDialogue(
context,
label: context.localized.videoScalingFillScreenTitle,
items: BoxFit.values,
itemBuilder: (type) => RadioListTile.adaptive(
title: Text(type?.label(context) ?? ""),
value: type,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
contentPadding: EdgeInsets.zero,
groupValue: ref.read(videoPlayerSettingsProvider.select((value) => value.videoFit)),
onChanged: (value) {
provider.setFitType(value);
Navigator.pop(context);
},
),
),
),
const Divider(),
SettingsLabelDivider(label: context.localized.advanced),
SettingsListTile(
label: Text(context.localized.settingsPlayerVideoHWAccelTitle),
subLabel: Text(context.localized.settingsPlayerVideoHWAccelDesc),
onTap: () => provider.setHardwareAccel(!videoSettings.hardwareAccel),
trailing: Switch(
value: videoSettings.hardwareAccel,
onChanged: (value) => provider.setHardwareAccel(value),
),
),
if (!kIsWeb) ...[
SettingsListTile(
label: Text(context.localized.settingsPlayerNativeLibassAccelTitle),
subLabel: Text(context.localized.settingsPlayerNativeLibassAccelDesc),
onTap: () => provider.setUseLibass(!videoSettings.useLibass),
trailing: Switch(
value: videoSettings.useLibass,
onChanged: (value) => provider.setUseLibass(value),
),
),
AnimatedFadeSize(
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid
? SettingsMessageBox(
context.localized.settingsPlayerMobileWarning,
messageType: MessageType.warning,
)
: Container(),
),
],
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: false,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
],
),
);
}
}

View file

@ -0,0 +1,119 @@
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/login/widgets/login_icon.dart';
import 'package:fladder/screens/shared/outlined_text_field.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> openQuickConnectDialog(
BuildContext context,
) async {
return showDialog(context: context, builder: (context) => QuickConnectDialog());
}
class QuickConnectDialog extends ConsumerStatefulWidget {
const QuickConnectDialog({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _QuickConnectDialogState();
}
class _QuickConnectDialogState extends ConsumerState<QuickConnectDialog> {
final controller = TextEditingController();
bool loading = false;
String? error;
String? success;
@override
Widget build(BuildContext context) {
final user = ref.watch(userProvider);
return AlertDialog.adaptive(
title: Text(context.localized.quickConnectTitle),
scrollable: true,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.localized.quickConnectAction),
if (user != null) SizedBox(child: LoginIcon(user: user)),
Flexible(
child: OutlinedTextField(
label: context.localized.code,
controller: controller,
keyboardType: TextInputType.number,
onChanged: (value) {
if (value.isNotEmpty) {
setState(() {
error = null;
success = null;
});
}
},
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 250),
child: error != null || success != null
? Card(
key: Key(context.localized.error),
color: success == null
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
success ?? error ?? "",
style: TextStyle(
color: success == null
? Theme.of(context).colorScheme.onErrorContainer
: Theme.of(context).colorScheme.onSurface),
),
),
)
: null,
),
ElevatedButton(
onPressed: loading
? null
: () async {
setState(() {
error = null;
loading = true;
});
final response = await ref.read(userProvider.notifier).quickConnect(controller.text);
if (response.isSuccessful) {
setState(
() {
error = null;
success = context.localized.loggedIn;
},
);
await Future.delayed(Duration(seconds: 2));
Navigator.of(context).pop();
} else {
if (controller.text.isEmpty) {
error = context.localized.quickConnectInputACode;
} else {
error = context.localized.quickConnectWrongCode;
}
}
loading = false;
setState(
() {},
);
controller.text = "";
},
child: loading
? SizedBox.square(
child: CircularProgressIndicator(),
dimension: 16.0,
)
: Text(context.localized.login),
)
].addInBetween(const SizedBox(height: 16)),
),
);
}
}

View file

@ -0,0 +1,41 @@
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart';
import 'package:fladder/screens/settings/widgets/settings_label_divider.dart';
import 'package:fladder/screens/shared/authenticate_button_options.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SecuritySettingsPage extends ConsumerStatefulWidget {
const SecuritySettingsPage({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _UserSettingsPageState();
}
class _UserSettingsPageState extends ConsumerState<SecuritySettingsPage> {
@override
Widget build(BuildContext context) {
final user = ref.watch(userProvider);
final showBackground = AdaptiveLayout.of(context).layout != LayoutState.phone &&
AdaptiveLayout.of(context).size != ScreenLayout.single;
return Card(
elevation: showBackground ? 2 : 0,
child: SettingsScaffold(
label: context.localized.settingsProfileTitle,
items: [
SettingsLabelDivider(label: context.localized.settingSecurityApplockTitle),
SettingsListTile(
label: Text(context.localized.settingSecurityApplockTitle),
subLabel: Text(user?.authMethod.name(context) ?? ""),
onTap: () => showAuthOptionsDialogue(context, user!, (newUser) {
ref.read(userProvider.notifier).updateUser(newUser);
}),
),
],
),
);
}
}

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
class SettingsListTile extends StatelessWidget {
final Widget label;
final Widget? subLabel;
final Widget? trailing;
final bool selected;
final IconData? icon;
final Widget? suffix;
final Color? contentColor;
final Function()? onTap;
const SettingsListTile({
required this.label,
this.subLabel,
this.trailing,
this.selected = false,
this.suffix,
this.icon,
this.contentColor,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final iconWidget = icon != null ? Icon(icon) : null;
return Card(
elevation: selected ? 2 : 0,
color: selected ? null : Colors.transparent,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(topLeft: Radius.circular(8), bottomLeft: Radius.circular(8))),
margin: EdgeInsets.zero,
child: ListTile(
minVerticalPadding: 12,
minLeadingWidth: 16,
minTileHeight: 75,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
horizontalTitleGap: 0,
titleAlignment: ListTileTitleAlignment.center,
contentPadding: const EdgeInsets.only(right: 12),
leading: (suffix ?? iconWidget) != null
? Padding(
padding: const EdgeInsets.only(left: 8.0, right: 16.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 125),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(selected ? 1 : 0),
borderRadius: BorderRadius.circular(selected ? 5 : 20),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12),
child: (suffix ?? iconWidget),
),
),
)
: suffix ?? const SizedBox(),
title: label,
titleTextStyle: Theme.of(context).textTheme.titleLarge,
trailing: Padding(
padding: const EdgeInsets.only(left: 16),
child: trailing,
),
selected: selected,
textColor: contentColor,
iconColor: contentColor,
subtitle: subLabel,
onTap: onTap,
),
);
}
}

View file

@ -0,0 +1,95 @@
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/shared/user_icon.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SettingsScaffold extends ConsumerWidget {
final String label;
final bool showUserIcon;
final ScrollController? scrollController;
final List<Widget> items;
final List<Widget> bottomActions;
final Widget? floatingActionButton;
const SettingsScaffold({
required this.label,
this.showUserIcon = false,
this.scrollController,
required this.items,
this.bottomActions = const [],
this.floatingActionButton,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final padding = MediaQuery.of(context).padding;
return Scaffold(
backgroundColor: AdaptiveLayout.of(context).isDesktop ? Colors.transparent : null,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: floatingActionButton,
body: Column(
children: [
Flexible(
child: CustomScrollView(
controller: scrollController,
slivers: [
if (AdaptiveLayout.of(context).size == ScreenLayout.single)
SliverAppBar.large(
titleSpacing: 20,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16)
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
title: Row(
children: [
Text(label, style: Theme.of(context).textTheme.headlineSmall),
const Spacer(),
if (showUserIcon)
SizedBox.fromSize(
size: const Size.fromRadius(14),
child: UserIcon(
user: ref.watch(userProvider),
cornerRadius: 200,
))
],
),
expandedTitleScale: 2,
),
expandedHeight: 175,
collapsedHeight: 100,
pinned: false,
floating: true,
)
else
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(AdaptiveLayout.of(context).size == ScreenLayout.single ? label : "",
style: Theme.of(context).textTheme.headlineLarge),
),
),
SliverList(
delegate: SliverChildListDelegate(items),
),
if (bottomActions.isEmpty)
const SliverToBoxAdapter(child: SizedBox(height: kBottomNavigationBarHeight + 40)),
],
),
),
if (bottomActions.isNotEmpty) ...{
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32)
.add(EdgeInsets.only(left: padding.left, right: padding.right)),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: bottomActions,
),
),
const SizedBox(height: kBottomNavigationBarHeight + 40),
},
],
),
);
}
}

View file

@ -0,0 +1,223 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/routes/build_routes/route_builder.dart';
import 'package:fladder/routes/build_routes/settings_routes.dart';
import 'package:fladder/screens/settings/quick_connect_window.dart';
import 'package:fladder/screens/settings/settings_list_tile.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/fladder_icon.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/theme_extensions.dart';
import 'package:fladder/widgets/shared/hide_on_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/auth_provider.dart';
import 'package:fladder/util/application_info.dart';
import 'package:go_router/go_router.dart';
class SettingsScreen extends ConsumerStatefulWidget {
final Widget? child;
final String? location;
const SettingsScreen({this.child, this.location, super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final scrollController = ScrollController();
late final singlePane = widget.child == null;
final minVerticalPadding = 20.0;
@override
Widget build(BuildContext context) {
if (singlePane) {
return Card(
elevation: 0,
child: _leftPane(context),
);
} else {
return Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 1, child: _leftPane(context)),
Expanded(
flex: 2,
child: widget.child ?? Container(),
),
],
);
}
}
IconData get deviceIcon {
if (AdaptiveLayout.of(context).isDesktop) {
return IconsaxOutline.monitor;
}
switch (AdaptiveLayout.of(context).layout) {
case LayoutState.phone:
return IconsaxOutline.mobile;
case LayoutState.tablet:
return IconsaxOutline.monitor;
case LayoutState.desktop:
return IconsaxOutline.monitor;
}
}
bool containsRoute(CustomRoute route) => widget.location == route.route;
Widget _leftPane(BuildContext context) {
final quickConnectAvailable =
ref.watch(userProvider.select((value) => value?.serverConfiguration?.quickConnectAvailable ?? false));
return SettingsScaffold(
label: context.localized.settings,
scrollController: scrollController,
showUserIcon: true,
items: [
if (context.canPop() && AdaptiveLayout.of(context).isDesktop)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: IconButton.filledTonal(
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.8),
),
onPressed: () {
context.pop();
},
icon: Padding(
padding: EdgeInsets.all(AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 0 : 4),
child: Icon(IconsaxOutline.arrow_left_2),
),
),
),
),
SettingsListTile(
label: Text(context.localized.settingsClientTitle),
subLabel: Text(context.localized.settingsClientDesc),
selected: containsRoute(ClientSettingsRoute()),
icon: deviceIcon,
onTap: () => context.routeReplaceOrPush(ClientSettingsRoute()),
),
if (quickConnectAvailable)
SettingsListTile(
label: Text(context.localized.settingsQuickConnectTitle),
icon: IconsaxOutline.password_check,
onTap: () => openQuickConnectDialog(context),
),
SettingsListTile(
label: Text(context.localized.settingsProfileTitle),
subLabel: Text(context.localized.settingsProfileDesc),
selected: containsRoute(SecuritySettingsRoute()),
icon: IconsaxOutline.security_user,
onTap: () => context.routeReplaceOrPush(SecuritySettingsRoute()),
),
SettingsListTile(
label: Text(context.localized.settingsPlayerTitle),
subLabel: Text(context.localized.settingsPlayerDesc),
selected: containsRoute(PlayerSettingsRoute()),
icon: IconsaxOutline.video_play,
onTap: () => context.routeReplaceOrPush(PlayerSettingsRoute()),
),
SettingsListTile(
label: Text(context.localized.about),
subLabel: Text("Fladder"),
suffix: Opacity(
opacity: 1,
child: FladderIconOutlined(
size: 24,
color: context.colors.onSurfaceVariant,
)),
onTap: () => showAboutDialog(
context: context,
applicationIcon: FladderIcon(size: 85),
applicationVersion: ref.watch(applicationInfoProvider).versionAndPlatform,
applicationLegalese: "Donut Factory",
),
),
],
floatingActionButton: HideOnScroll(
controller: scrollController,
visibleBuilder: (visible) {
return AnimatedFadeSize(
child: visible
? Padding(
padding: EdgeInsets.symmetric(horizontal: MediaQuery.paddingOf(context).horizontal),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Spacer(),
FloatingActionButton(
key: Key(context.localized.switchUser),
tooltip: context.localized.switchUser,
onPressed: () {
ref.read(userProvider.notifier).logoutUser();
context.routeGo(LoginRoute());
},
child: const Icon(
IconsaxOutline.arrow_swap_horizontal,
),
),
const SizedBox(width: 16),
FloatingActionButton(
key: Key(context.localized.logout),
tooltip: context.localized.logout,
backgroundColor: Theme.of(context).colorScheme.errorContainer,
onPressed: () {
final user = ref.read(userProvider);
showDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Text(context.localized.logoutUserPopupTitle(user?.name ?? "")),
scrollable: true,
content: Text(
context.localized.logoutUserPopupContent(user?.name ?? "", user?.server ?? ""),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: Text(context.localized.cancel),
),
ElevatedButton(
style: ElevatedButton.styleFrom().copyWith(
foregroundColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.onErrorContainer),
backgroundColor:
WidgetStatePropertyAll(Theme.of(context).colorScheme.errorContainer),
),
onPressed: () async {
await ref.read(authProvider.notifier).logOutUser();
if (context.mounted) context.routeGo(SplashRoute());
},
child: Text(context.localized.logout),
),
],
),
);
},
child: Icon(
IconsaxOutline.logout,
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
)
: Container(
height: 0,
key: UniqueKey(),
),
);
},
),
);
}
}

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SettingsLabelDivider extends ConsumerWidget {
final String label;
const SettingsLabelDivider({required this.label, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8).add(
EdgeInsets.symmetric(
horizontal: MediaQuery.paddingOf(context).horizontal,
),
),
child: Text(
label,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
enum MessageType {
info,
warning,
error;
Color color(BuildContext context) {
switch (this) {
case info:
return Theme.of(context).colorScheme.surface;
case warning:
return Theme.of(context).colorScheme.primaryContainer;
case error:
return Theme.of(context).colorScheme.errorContainer;
}
}
}
class SettingsMessageBox extends ConsumerWidget {
final String message;
final MessageType messageType;
const SettingsMessageBox(this.message, {this.messageType = MessageType.info, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8).add(
EdgeInsets.symmetric(
horizontal: MediaQuery.paddingOf(context).horizontal,
),
),
child: Card(
elevation: 2,
color: messageType.color(context),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(message),
),
),
),
);
}
}

View file

@ -0,0 +1,94 @@
import 'package:fladder/models/settings/subtitle_settings_model.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_appbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ignore: depend_on_referenced_packages
class SubtitleEditor extends ConsumerStatefulWidget {
const SubtitleEditor({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SubtitleEditorState();
}
class _SubtitleEditorState extends ConsumerState<SubtitleEditor> {
@override
Widget build(BuildContext context) {
final settings = ref.watch(subtitleSettingsProvider);
final fillScreen = ref.watch(videoPlayerSettingsProvider.select((value) => value.fillScreen));
final padding = MediaQuery.of(context).padding;
final fakeText = context.localized.subtitleConfiguratorPlaceHolder;
double lastScale = 0.0;
return Scaffold(
body: Dialog.fullscreen(
child: GestureDetector(
onScaleUpdate: (details) {
lastScale = details.scale;
},
onScaleEnd: (details) {
if (lastScale < 1.0) {
ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(false, context: context);
} else if (lastScale > 1.0) {
ref.read(videoPlayerSettingsProvider.notifier).setFillScreen(true, context: context);
}
lastScale = 0.0;
},
child: Stack(
children: [
Padding(
padding: (fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right)),
child: const Center(
child: AspectRatio(
aspectRatio: 2.1,
child: Card(
child: Image(
image: BlurHashImage('LEF}}|0000~p8w~W%N4n~pIU4o%g'),
fit: BoxFit.fill,
),
),
),
),
),
SubtitleText(subModel: settings, padding: padding, offset: settings.verticalOffset, text: fakeText),
Align(
alignment: Alignment.topCenter,
child: Padding(
padding:
MediaQuery.paddingOf(context).add(const EdgeInsets.all(32).add(const EdgeInsets.only(top: 48))),
child: SizedBox(
width: MediaQuery.sizeOf(context).width * 0.95,
child: const VideoSubtitleControls(),
),
),
),
Padding(
padding: MediaQuery.paddingOf(context),
child: Column(
children: [
if (AdaptiveLayout.of(context).isDesktop) const FladderAppbar(),
Row(
children: [
const BackButton(),
Text(
context.localized.subtitleConfigurator,
style: Theme.of(context).textTheme.headlineMedium,
)
],
)
],
),
),
],
),
),
),
);
}
}