diff --git a/android/app/build.gradle b/android/app/build.gradle index 77804881..c802ff9d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -58,6 +58,7 @@ android { targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + manifestPlaceholders['foregroundServiceType'] = 'mediaPlayback'; } signingConfigs { release { diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 783a9750..c6445662 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -9,6 +9,8 @@ + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index aeb2ad1e..d22329bb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index b74f31e4..7d91f297 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -7,6 +7,8 @@ + + diff --git a/ios/Runner/Info-Debug.plist b/ios/Runner/Info-Debug.plist index 4a32f05d..076d039c 100755 --- a/ios/Runner/Info-Debug.plist +++ b/ios/Runner/Info-Debug.plist @@ -17,26 +17,26 @@ CFBundleLocalizations en - ar + ar ca - cs + cs da - de - en_pirate - es - es_US - eu - fr - he - hr - hu - id - it - ja - kk - ko - lb - nb + de + en_pirate + es + es_US + eu + fr + he + hr + hu + id + it + ja + kk + ko + lb + nb nl nn nl @@ -96,6 +96,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSPhotoLibraryUsageDescription + Allow user to upload images to their server or local device to change ebook covers UIViewControllerBasedStatusBarAppearance diff --git a/ios/Runner/Info-Release.plist b/ios/Runner/Info-Release.plist index 62c7b62a..7d4c527c 100755 --- a/ios/Runner/Info-Release.plist +++ b/ios/Runner/Info-Release.plist @@ -1,61 +1,63 @@ - - ITSAppUsesNonExemptEncryption - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Jellybook - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - jellybook - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - LSSupportsOpeningDocumentsInPlace - - UIFileSharingEnabled - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - BGTaskSchedulerPermittedIdentifiers - - dev.flutter.background.refresh - - + + ITSAppUsesNonExemptEncryption + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Jellybook + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + jellybook + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + LSSupportsOpeningDocumentsInPlace + + UIFileSharingEnabled + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSPhotoLibraryUsageDescription + Allow user to upload images to their server or local device to change ebook covers + BGTaskSchedulerPermittedIdentifiers + + dev.flutter.background.refresh + + diff --git a/lib/providers/Author.dart b/lib/providers/Author.dart new file mode 100644 index 00000000..c6573547 --- /dev/null +++ b/lib/providers/Author.dart @@ -0,0 +1,14 @@ +class Author { + final String name; + String? link; + List roles = []; + + Author({ + required this.name, + this.link, + }); + + void addRole(String role) { + roles.add(role); + } +} diff --git a/lib/providers/fixRichText.dart b/lib/providers/fixRichText.dart index 95408f85..e743d4a9 100644 --- a/lib/providers/fixRichText.dart +++ b/lib/providers/fixRichText.dart @@ -10,7 +10,7 @@ String fixRichText(String text) { .replaceAll("", "*") .replaceAll("", "**") .replaceAll("", "**") - .replaceAll("

", "\t") + .replaceAll("

", "\n\n") .replaceAll("

", "") .replaceAll("", "__") .replaceAll("", "__") diff --git a/lib/screens/EditScreen.dart b/lib/screens/EditScreen.dart new file mode 100644 index 00000000..3a82d4d0 --- /dev/null +++ b/lib/screens/EditScreen.dart @@ -0,0 +1,884 @@ +// the purpose of this file is to allow you to edit a entry in your library + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_star/flutter_star.dart'; +import 'package:intl/intl.dart'; +import 'package:jellybook/providers/fixRichText.dart'; +import 'package:jellybook/widgets/ToggleEditPreviewButton.dart'; +import 'package:jellybook/widgets/roundedImageWithShadow.dart'; +import 'package:jellybook/providers/updateLike.dart'; +import 'package:isar/isar.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:jellybook/models/entry.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:jellybook/variables.dart'; +import 'package:package_info_plus/package_info_plus.dart' as p_info; +import 'package:openapi/openapi.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'dart:io'; + +class EditScreen extends StatefulWidget { + bool offline; + Entry entry; + + EditScreen({ + super.key, + required this.entry, + this.offline = false, + }); + + @override + _EditScreenState createState() => _EditScreenState( + entry: entry, + offline: offline, + ); +} + +class _EditScreenState extends State { + bool editMode = true; + bool offline; + bool changed = false; + Entry entry; + _EditScreenState({ + required this.entry, + this.offline = false, + }); + double imageWidth = 0; + bool updatedImageWidth = false; + bool isEditingDescription = false; + final picker = ImagePicker(); + TextEditingController _descriptionController = TextEditingController(); + final TextEditingController _tagController = TextEditingController(); + List tags = []; + TextEditingController _titleController = TextEditingController(); + final FocusNode _titleFocusNode = FocusNode(); + TextEditingController _releaseDateController = TextEditingController(); + +// check if it is liked or not by checking the database + Future checkLiked(String id) async { + final isar = Isar.getInstance(); + final entries = await isar?.entrys.where().idEqualTo(id).findFirst(); + + return entries?.isFavorited ?? false; + } + + @override + void initState() { + super.initState(); + checkLiked(entry.id).then((value) { + entry.isFavorited = value; + }); + tags = List.from(entry.tags); + _titleController = TextEditingController(text: entry.title); + _releaseDateController = TextEditingController(text: entry.releaseDate); + _descriptionController = TextEditingController(text: entry.description); + logger.i("isarId: ${entry.isarId}"); + logger.i("rating: ${entry.rating}"); + if (entry.rating < 0) { + entry.rating = 0; + } + } + + @override + void dispose() { + super.dispose(); + } + + void handleImageSize(Size imageSize) { + // Do something with the image size obtained from the callback + logger.f('Image size: ${imageSize.width} x ${imageSize.height}'); + // wait until build is done then set state but do it only once + if (!updatedImageWidth) { + setState(() { + imageWidth = imageSize.width; + updatedImageWidth = true; + }); + } + } + + // pick an image from the gallery + Future _pickImage() async { + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) { + entry.imagePath = pickedFile.path; + setState(() {}); + } + } + + bool isTablet(BuildContext context) { + final shortestSide = MediaQuery.of(context).size.shortestSide; + + return shortestSide > 600; + } + + Future saveToDevice() async { + final isar = Isar.getInstance(); + entry.tags = tags; + entry.description = _descriptionController.text; + + await isar?.writeTxn(() async { + await isar.entrys.put(entry); + }); + } + + Future saveToServer() async { + final isar = Isar.getInstance(); + bool imageChanged = false; + final entry2 = + await isar!.entrys.where().idEqualTo(this.entry.id).findFirst(); + if (entry2!.imagePath != entry.imagePath) { + imageChanged = true; + } + entry.tags = tags; + entry.description = _descriptionController.text; + + await saveToDevice(); + + final prefs = await SharedPreferences.getInstance(); + p_info.PackageInfo packageInfo = await p_info.PackageInfo.fromPlatform(); + final server = prefs.getString('server')!; + final token = prefs.getString('accessToken')!; + final version = packageInfo.version; + const _client = "JellyBook"; + const _device = "Unknown Device"; + const _deviceId = "Unknown Device id"; + + final url = server + '/Items/' + entry.id; + var headers = { + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + 'Content-Type': 'application/json', + "X-Emby-Authorization": + "MediaBrowser Client=\"$_client\", Device=\"$_device\", DeviceId=\"$_deviceId\", Version=\"$version\", Token=\"$token\"", + 'Connection': 'keep-alive', + 'Origin': server, + 'Host': server.substring(server.indexOf("//") + 2, server.length), + }; + final api = Openapi(basePathOverride: server).getItemUpdateApi(); + // body of the request + try { + final response = await api.updateItem( + itemId: entry.id, + updateItemRequest: UpdateItemRequest( + (b) => { + b.name = entry.title, + b.overview = entry.description, + b.premiereDate = DateTime.parse(entry.releaseDate), + b.tags = ListBuilder(tags), + }, + ), + headers: headers, + ); + // logger.d(response.data.toString()); + } catch (e) { + logger.e(e.toString()); + // display error message + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + AppLocalizations.of(context)?.error ?? "Error", + ), + content: Text( + e.toString(), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + AppLocalizations.of(context)?.ok ?? "Ok", + ), + ), + ], + ); + }, + ); + } + if (imageChanged) { + final api2 = Openapi(basePathOverride: server).getImageApi(); + // get the image encoded in base64 + File imagefile = File(entry.imagePath); + MultipartFile file = await MultipartFile.fromFile( + entry.imagePath, + filename: entry.imagePath, + ); + // get length of file + headers = { + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate', + 'Content-Type': 'multipart/form-data', + "X-Emby-Authorization": + "MediaBrowser Client=\"$_client\", Device=\"$_device\", DeviceId=\"$_deviceId\", Version=\"$version\", Token=\"$token\"", + 'Connection': 'keep-alive', + 'Origin': server, + 'Host': server.substring(server.indexOf("//") + 2, server.length), + 'Content-Length': '${file.length}', + }; + try { + final response = await api2.setItemImage( + itemId: entry.id, + imageType: ImageType.primary, + headers: headers, + ); + // tell if the image was uploaded or not + } catch (e) { + logger.e(e.toString()); + } + } + logger.d(url); + // if (entries?.isFavorited == false) { + // try { + // final response = await api.unmarkFavoriteItem( + // userId: userId, itemId: id, headers: headers, url: server); + // logger.d(response.data.toString()); + // } catch (e) { + // logger.e(e.toString()); + // } + // } else { + // try { + // final response = await api.markFavoriteItem( + // userId: userId, itemId: id, headers: headers, url: server); + // logger.d(response.data.toString()); + // } catch (e) { + // logger.e(e.toString()); + // } + // } + } + + void toggleEditDescription() { + setState(() { + isEditingDescription = !isEditingDescription; + }); + } + + void _insertText(String textToInsertFront, String textToInsertBack) { + final text = _descriptionController.text; + final textSelection = _descriptionController.selection; + // if highlighting text, then wrap the text in the markdown + // if not, then just insert the markdown + if (textSelection.start != textSelection.end) { + final textBefore = text.substring(0, textSelection.start); + final textAfter = text.substring(textSelection.end, text.length); + final newText = '$textBefore$textToInsertFront' + '${text.substring(textSelection.start, textSelection.end)}' + '$textToInsertBack$textAfter'; + _descriptionController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: textBefore.length + + textToInsertFront.length + + textSelection.end - + textSelection.start, + ), + ); + } else { + final newText = '$text$textToInsertFront$textToInsertBack'; + _descriptionController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: text.length + textToInsertFront.length, + ), + ); + } + } + + ButtonStyle _buttonStyle(bool isSelected) { + return TextButton.styleFrom( + foregroundColor: isSelected + ? Theme.of(context).primaryTextTheme.labelLarge!.color + : Colors.grey, + // set the background color to a non-transparent color if selected and a different non-transparent color if not selected + backgroundColor: isSelected + ? Theme.of(context).primaryTextTheme.labelLarge?.background?.color ?? + Colors.purple.withOpacity(0.2) + : Colors.grey.withOpacity(0.2), + ); + } + + Widget _buildMarkdownToolbar() { + return Container( + // set the size of the container to be the same as the buttons + height: 40, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: Colors.purple.withOpacity(0.2), + // color: Theme.of(context).primaryColor.withOpacity(0.2), + ), + // Adjust the style accordingly + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Bold button + IconButton( + icon: Icon(Icons.format_bold), + onPressed: () => _insertText('**', '**'), + ), + // Italic button + IconButton( + icon: Icon(Icons.format_italic), + onPressed: () => _insertText('_', '_'), + ), + // Link button + IconButton( + icon: Icon(Icons.link), + onPressed: () => _insertText('[', '](url)'), + ), + // H1 button + IconButton( + icon: Icon(Icons.looks_one), + onPressed: () => _insertText('# ', ''), + ), + // List button + IconButton( + icon: Icon(Icons.list), + onPressed: () => _insertText('\n- ', ''), + ), + // Code button + IconButton( + icon: Icon(Icons.code), + onPressed: () => _insertText('`', '`'), + ), + // Add more buttons for italic, code blocks, etc. + ], + ), + ); + } + + void _showDatePicker(BuildContext context) { + // try to parse it into a date time + // if it fails, then use the current date time + DateTime dateTime; + try { + dateTime = DateTime.parse(entry.releaseDate); + } catch (e) { + try { + dateTime = DateTime.parse( + entry.releaseDate.replaceAll(' ', '-'), + ); + } catch (e) { + dateTime = DateTime.now(); + } + } + showModalBottomSheet( + context: context, + builder: (BuildContext builder) { + return Container( + height: MediaQuery.of(context).copyWith().size.height / 3, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: dateTime, + onDateTimeChanged: (DateTime newDate) { + setState(() { + // entry.releaseDate = newDate.toString(); + // only the month, day, and year + entry.releaseDate = DateFormat('dd MM yyyy').format(newDate); + }); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + // update the page so that the liked comics are at the top + Navigator.pop(context, entry); + }, + ), + // trailing button to toggle edit mode + actions: [ + IconButton( + // if edit mode is true, show a preview button, else show an edit button + icon: Icon( + editMode ? Icons.visibility : Icons.edit, + ), + onPressed: () { + setState(() { + editMode = !editMode; + }); + }, + ), + ], + ), + body: SingleChildScrollView( + child: !editMode + ? Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 16, 0), + child: RoundedImageWithShadow( + imageUrl: entry.imagePath, + radius: 15, + ratio: 0.75, + size: Size(MediaQuery.of(context).size.width * 0.3, + MediaQuery.of(context).size.width * 0.3 * 1.5), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: + MediaQuery.of(context).size.width * 0.5, + // child: TextField( + // style: const TextStyle( + // fontSize: 20, + // ), + // controller: _titleController, + // focusNode: _titleFocusNode, + // onChanged: (value) { + // entry.title = value; + // }, + // decoration: InputDecoration( + // hintText: 'Enter Title Here', + // border: InputBorder.none, + // ), + // ), + child: AutoSizeText( + entry.title, + maxLines: 2, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 30, + ), + // textAlign: TextAlign.left, + ), + ), + ], + ), + const SizedBox( + height: 10, + ), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width * 0.55, + ), + child: Row( + children: [ + Text( + "Release Date: ", + style: TextStyle( + fontSize: 20, + ), + ), + const Spacer(flex: 1), + // regular text instead of button + Text( + entry.releaseDate, + style: const TextStyle( + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox( + height: 5, + ), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width * 0.55, + ), + child: Row( + children: [ + Text( + "Rating: ", + style: TextStyle( + fontSize: 20, + ), + ), + Spacer(flex: 1), + IgnorePointer( + child: CustomRating( + max: 5, + score: entry.rating / 2, + star: Star( + fillColor: Color.lerp( + Colors.red, + Colors.yellow, + entry.rating / 10, + )!, + emptyColor: + Colors.grey.withOpacity(0.5), + ), + onRating: (double score) { + entry.rating = score * 2; + setState(() {}); + }, + ), + ), + ], + ), + ), + ], + ), + ], + ), + const SizedBox( + height: 10, + ), + // allow users to edit the description in rich text + // add a button that displays the rich text, and when clicked it will display the markdown that you can edit + // 2 buttons at the bottom, one says save to device and the says save to server + // Expanded( + // text saying description + Text( + "Description:", + style: TextStyle( + fontSize: 20, + ), + ), + MarkdownBody( + data: fixRichText( + _descriptionController.text, + ), + extensionSet: md.ExtensionSet( + md.ExtensionSet.gitHubFlavored.blockSyntaxes, + [ + md.EmojiSyntax(), + ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes + ], + ), + ), + const SizedBox( + height: 10, + ), + + if (tags.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: tags.length, + itemBuilder: (context, index) { + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 5), + child: Chip( + label: Text(tags[index]), + ), + ); + }, + ), + ), + ), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 16, 0), + child: Stack( + children: [ + RoundedImageWithShadow( + imageUrl: entry.imagePath, + radius: 15, + ratio: 0.75, + size: Size( + MediaQuery.of(context).size.width * 0.3, + MediaQuery.of(context).size.width * + 0.3 * + 1.5), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => ( + _pickImage(), + changed = true, + ), + child: Container( + decoration: BoxDecoration( + color: Colors.grey.withOpacity( + 0.5, + ), // Semi-transparent gray overlay + borderRadius: BorderRadius.circular( + 15, + ), // Match the border radius of the image + ), + child: const Center( + child: Icon( + Icons + .camera_alt, // Icon indicating that you can upload an image + color: Colors.white, + size: 50, + ), + ), + ), + ), + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: + MediaQuery.of(context).size.width * 0.5, + child: TextField( + style: const TextStyle( + fontSize: 20, + ), + controller: _titleController, + focusNode: _titleFocusNode, + onChanged: (value) { + entry.title = value; + changed = true; + }, + ), + ), + ], + ), + // Release date + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width * 0.55, + ), + child: Row( + children: [ + Text( + "Release Date: ", + style: TextStyle( + fontSize: 20, + ), + ), + Spacer(flex: 1), + TextButton( + onPressed: () { + _showDatePicker(context); + changed = true; + }, + child: Text( + entry.releaseDate, + style: TextStyle( + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox( + height: 5, + ), + // Rating + // allign left + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width * 0.55, + ), + child: Row( + children: [ + Text( + "Rating: ", + style: TextStyle( + fontSize: 20, + ), + ), + Spacer(flex: 1), + CustomRating( + max: 5, + score: entry.rating / 2, + star: Star( + fillColor: Color.lerp(Colors.red, + Colors.yellow, entry.rating / 10)!, + emptyColor: Colors.grey.withOpacity(0.5), + ), + onRating: (double score) { + entry.rating = score * 2; + changed = true; + setState(() {}); + }, + ), + ], + ), + ), + ], + ), + ], + ), + const SizedBox( + height: 10, + ), + // allow users to edit the description in rich text + // add a button that displays the rich text, and when clicked it will display the markdown that you can edit + // 2 buttons at the bottom, one says save to device and the says save to server + // Expanded( + // text saying description + Text( + "Description:", + style: TextStyle( + fontSize: 20, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [], + ), + SizedBox( + height: 10, + ), + _buildMarkdownToolbar(), + SizedBox( + height: 10, + ), + // OutlinedButton( + // onPressed: () => setState(() { + // isEditingDescription = !isEditingDescription; + // }), + // child: Text('Preview')), + TextField( + controller: _descriptionController, + keyboardType: TextInputType.multiline, + maxLines: null, // Makes it expandable + decoration: InputDecoration( + border: OutlineInputBorder(), + ), + ), + const SizedBox( + height: 10, + ), + + // Editing tags + TextField( + controller: _tagController, + decoration: InputDecoration( + labelText: 'Add a tag', + suffixIcon: IconButton( + icon: Icon(Icons.add), + onPressed: () { + // add the tag + setState(() { + changed = true; + if (_tagController.text.isNotEmpty) { + tags = List.from(tags) + ..add(_tagController.text); + } + _tagController.clear(); + }); + }, + ), + ), + onSubmitted: (value) { + setState(() { + if (value.isNotEmpty) { + tags = List.from(tags)..add(value); + } + }); + }, + ), + ReorderableListView( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + onReorder: (int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + changed = true; + setState(() { + tags = List.from( + tags + ..insert( + newIndex, + tags.removeAt(oldIndex), + ), + ); + }); + }); + }, + children: List.generate(tags.length, (index) { + return ListTile( + key: ValueKey(tags[index]), + title: Text(tags[index]), + trailing: IconButton( + icon: Icon(Icons.delete), + onPressed: () { + setState(() { + tags = List.from(tags..removeAt(index)); + changed = true; + }); + }, + ), + ); + }), + ), + SizedBox( + height: 10, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: saveToDevice, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.40, + child: const Center( + child: Text("Save to device"), + ), + ), + ), + if (!offline) + TextButton( + onPressed: saveToServer, + // onPressed: saveToServer, + // child: Text("Save to server"), + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.40, + child: const Center( + child: Text("Save to server"), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/MainScreens/downloadsScreen.dart b/lib/screens/MainScreens/downloadsScreen.dart index f1b5dd89..669a3689 100644 --- a/lib/screens/MainScreens/downloadsScreen.dart +++ b/lib/screens/MainScreens/downloadsScreen.dart @@ -1,6 +1,7 @@ // The purpose of this file is to have a screen with all the downloaded books with easy access to delete them import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:jellybook/screens/collectionScreen.dart'; import 'package:jellybook/screens/infoScreen.dart'; import 'package:isar/isar.dart'; @@ -201,19 +202,20 @@ class _DownloadsScreenState extends State { if (snapshot.data[index].rating < 0 && snapshot.data[index].description != '') Flexible( - child: RichText( - text: TextSpan( - text: fixRichText( - snapshot.data[index].description), - style: const TextStyle( - fontStyle: FontStyle.italic, - fontSize: 15, - color: Colors.grey, - ), - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, + child: MarkdownBody( + // text: TextSpan( + data: fixRichText( + snapshot.data[index].description), ), + // style: const TextStyle( + // fontStyle: FontStyle.italic, + // fontSize: 15, + // color: Colors.grey, + // ), + // ), + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // ), ), ], ), diff --git a/lib/screens/MainScreens/settingsScreen.dart b/lib/screens/MainScreens/settingsScreen.dart index c32cb3f6..16ad45e9 100644 --- a/lib/screens/MainScreens/settingsScreen.dart +++ b/lib/screens/MainScreens/settingsScreen.dart @@ -33,7 +33,9 @@ class _SettingsScreenState extends State { getPackageInfo(); super.initState(); // Settings.init(); - setSharedPrefs(); + setSharedPrefs().then((value) { + setState(() {}); + }); } themeListener() { @@ -223,7 +225,7 @@ class _SettingsScreenState extends State { Future themeSettings(BuildContext context) async { return SettingsItem( title: AppLocalizations.of(context)?.theme ?? 'theme', - selected: prefs?.getString('theme') ?? 'system', + selected: await prefs!.getString('theme') ?? 'system', backgroundColor: Theme.of(context).splashColor, icon: Icons.color_lens, values: { diff --git a/lib/screens/collectionScreen.dart b/lib/screens/collectionScreen.dart index 08d9041a..73f00979 100644 --- a/lib/screens/collectionScreen.dart +++ b/lib/screens/collectionScreen.dart @@ -1,6 +1,8 @@ // The purpose of this file is to create a list of entries from a selected folder // import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; import 'package:isar/isar.dart'; import 'package:isar_flutter_libs/isar_flutter_libs.dart'; import 'package:jellybook/models/entry.dart'; @@ -163,19 +165,24 @@ class _collectionScreenState extends State { if (snapshot.data[index].rating < 0 && snapshot.data[index].description != '') Flexible( - child: RichText( - text: TextSpan( - text: - fixRichText(snapshot.data[index].description), - // prevent overflow - style: const TextStyle( - fontStyle: FontStyle.italic, - fontSize: 15, - color: Colors.grey, - ), + child: MarkdownBody( + data: fixRichText(snapshot.data[index].description), + extensionSet: md.ExtensionSet( + md.ExtensionSet.gitHubFlavored.blockSyntaxes, + [ + md.EmojiSyntax(), + ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes + ], ), - overflow: TextOverflow.ellipsis, - maxLines: 1, + // prevent overflow + // style: const TextStyle( + // fontStyle: FontStyle.italic, + // fontSize: 15, + // color: Colors.grey, + // ), + // ), + // overflow: TextOverflow.ellipsis, + // maxLines: 1, ), ), ], diff --git a/lib/screens/infoScreen.dart b/lib/screens/infoScreen.dart index 4198cf04..3e1070dc 100644 --- a/lib/screens/infoScreen.dart +++ b/lib/screens/infoScreen.dart @@ -7,18 +7,21 @@ import 'package:jellybook/models/login.dart'; import 'package:jellybook/providers/deleteComic.dart'; import 'package:jellybook/providers/fixRichText.dart'; import 'package:jellybook/screens/downloaderScreen.dart'; +import 'package:jellybook/screens/EditScreen.dart'; import 'package:jellybook/screens/readingScreen.dart'; import 'package:jellybook/widgets/roundedImageWithShadow.dart'; import 'package:like_button/like_button.dart'; import 'package:jellybook/providers/updateLike.dart'; import 'package:isar/isar.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; import 'package:jellybook/models/entry.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:jellybook/variables.dart'; import 'package:package_info_plus/package_info_plus.dart' as p_info; import 'package:openapi/openapi.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jellybook/providers/Author.dart'; class InfoScreen extends StatefulWidget { bool offline; @@ -499,6 +502,20 @@ class _InfoScreenState extends State { logger.d("deleting comic"); await deleteComic(entry.id, context); } + if (value == "edit") { + logger.d("editing comic"); + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + EditScreen(entry: entry, offline: offline), + ), + ); + setState(() { + if (result != null) entry = result; + }); + // } + } }, child: const Icon(Icons.more_vert, color: Colors.white), itemBuilder: (context) { @@ -515,6 +532,16 @@ class _InfoScreenState extends State { ], ), ), + PopupMenuItem( + value: "edit", + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 10), + Text(AppLocalizations.of(context)?.edit ?? "Edit"), + ], + ), + ), ]; }, ), @@ -660,6 +687,13 @@ class _InfoScreenState extends State { padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), child: MarkdownBody( data: fixRichText(entry.description), + extensionSet: md.ExtensionSet( + md.ExtensionSet.gitHubFlavored.blockSyntaxes, + [ + md.EmojiSyntax(), + ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes + ], + ), selectable: false, shrinkWrap: true, styleSheet: @@ -799,18 +833,3 @@ class _InfoScreenState extends State { ); } } - -class Author { - final String name; - String? link; - List roles = []; - - Author({ - required this.name, - this.link, - }); - - void addRole(String role) { - roles.add(role); - } -} diff --git a/lib/widgets/ToggleEditPreviewButton.dart b/lib/widgets/ToggleEditPreviewButton.dart new file mode 100644 index 00000000..d49ef2b2 --- /dev/null +++ b/lib/widgets/ToggleEditPreviewButton.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +class ToggleEditPreviewButton extends StatefulWidget { + final bool isEditing; + final VoidCallback onEdit; + final VoidCallback onPreview; + final double width; + + const ToggleEditPreviewButton({ + Key? key, + required this.isEditing, + required this.onEdit, + required this.onPreview, + this.width = 0.4, + }) : super(key: key); + + @override + _ToggleEditPreviewButtonState createState() => + _ToggleEditPreviewButtonState(); +} + +class _ToggleEditPreviewButtonState extends State { + late bool isEditing; + + @override + void initState() { + super.initState(); + isEditing = widget.isEditing; + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * widget.width, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: isEditing + ? Theme.of(context).colorScheme.onPrimary + : Colors.grey, + backgroundColor: isEditing + ? Theme.of(context).colorScheme.primary + : Colors.grey.withOpacity(0.2), + ), + onPressed: () { + if (!isEditing) { + setState(() { + isEditing = true; + }); + widget.onEdit(); + } + }, + child: const Text('Edit'), + ), + ), + SizedBox( + width: 10, + ), + SizedBox( + width: MediaQuery.of(context).size.width * widget.width, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: !isEditing + ? Theme.of(context).colorScheme.onPrimary + : Colors.grey, + backgroundColor: !isEditing + ? Theme.of(context).colorScheme.primary + : Colors.grey.withOpacity(0.2), + ), + onPressed: () { + if (isEditing) { + setState(() { + isEditing = false; + }); + widget.onPreview(); + } + }, + child: const Text('Preview'), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/roundedImageWithShadow.dart b/lib/widgets/roundedImageWithShadow.dart index b3846e03..bd70a05d 100644 --- a/lib/widgets/roundedImageWithShadow.dart +++ b/lib/widgets/roundedImageWithShadow.dart @@ -1,3 +1,8 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:fancy_shimmer_image/fancy_shimmer_image.dart'; + import 'package:flutter/material.dart'; import 'package:fancy_shimmer_image/fancy_shimmer_image.dart'; @@ -8,6 +13,7 @@ class RoundedImageWithShadow extends StatefulWidget { final Color shadowColor; final Function(Size)? onImageSizeAvailable; final String errorWidgetAsset; + final Size? size; // Optional size parameter const RoundedImageWithShadow({ super.key, @@ -17,6 +23,7 @@ class RoundedImageWithShadow extends StatefulWidget { this.shadowColor = Colors.black, this.onImageSizeAvailable, this.errorWidgetAsset = 'assets/images/NoCoverArt.png', + this.size, // Add size to the constructor }); @override @@ -36,14 +43,13 @@ class _RoundedImageWithShadowState extends State { }); } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { return Container( + width: widget + .size?.width, // Use the width from the size parameter if provided + height: widget + .size?.height, // Use the height from the size parameter if provided decoration: BoxDecoration( borderRadius: BorderRadius.circular(widget.radius), boxShadow: [ @@ -59,32 +65,41 @@ class _RoundedImageWithShadowState extends State { borderRadius: BorderRadius.circular(widget.radius), child: AspectRatio( aspectRatio: widget.ratio, - child: - widget.imageUrl == '' || widget.imageUrl.toLowerCase() == 'asset' - ? LayoutBuilder( - builder: (context, constraints) { - imageSize = constraints.biggest; + child: widget.imageUrl == '' || + widget.imageUrl.toLowerCase() == 'asset' + ? LayoutBuilder( + builder: (context, constraints) { + imageSize = widget.size ?? constraints.biggest; - return Image.asset( - widget.errorWidgetAsset, - fit: BoxFit.cover, - ); - }, - ) - : LayoutBuilder( + return Image.asset( + widget.errorWidgetAsset, + fit: BoxFit.cover, + width: + imageSize?.width, // Use the width from the imageSize + height: imageSize + ?.height, // Use the height from the imageSize + ); + }, + ) + : widget.imageUrl.contains('http') + ? LayoutBuilder( builder: (context, constraints) { - imageSize = constraints.biggest; + imageSize = widget.size ?? constraints.biggest; return FancyShimmerImage( - width: constraints.biggest.width, imageUrl: widget.imageUrl, boxFit: BoxFit.cover, errorWidget: Image.asset( widget.errorWidgetAsset, + width: imageSize + ?.width, // Use the width from the imageSize + height: imageSize + ?.height, // Use the height from the imageSize ), ); }, - ), + ) + : Image.file(File(widget.imageUrl), fit: BoxFit.cover), ), ), );