diff --git a/README.md b/README.md index 5a7f0f6..9edc190 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,14 @@ Shlink Manager is an app for Android to see and manage all shortened URLs create ✅ Display tags
✅ Display QR code
✅ Dark mode support
+✅ Edit existing short URLs
## 🔨 To Do -- [ ] Edit existing short URLs - [ ] Add support for iOS (maybe in the future) - [ ] add tags - [ ] specify individual long URLs per device - [ ] improve app icon +- [ ] Refactor code - [ ] ...and more ## 💻 Development diff --git a/lib/API/Classes/ShortURL/short_url.dart b/lib/API/Classes/ShortURL/short_url.dart index 82d0f01..2e26f0d 100644 --- a/lib/API/Classes/ShortURL/short_url.dart +++ b/lib/API/Classes/ShortURL/short_url.dart @@ -63,4 +63,16 @@ class ShortURL { domain = json["domain"], title = json["title"], crawlable = json["crawlable"]; + ShortURL.empty() + : shortCode = "", + shortUrl = "", + longUrl = "", + deviceLongUrls = DeviceLongUrls("", "", ""), + dateCreated = DateTime.now(), + visitsSummary = VisitsSummary(0, 0, 0), + tags = [], + meta = ShortURLMeta(DateTime.now(), DateTime.now(), 0), + domain = "", + title = "", + crawlable = false; } diff --git a/lib/API/Methods/submit_short_url.dart b/lib/API/Methods/submit_short_url.dart index 9d3f580..dbdeb43 100644 --- a/lib/API/Methods/submit_short_url.dart +++ b/lib/API/Methods/submit_short_url.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'dart:convert'; import 'package:dartz/dartz.dart'; import 'package:http/http.dart' as http; +import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; import '../server_manager.dart'; /// Submits a short URL to a server for it to be added -FutureOr> apiSubmitShortUrl(ShortURLSubmission shortUrl, +FutureOr> apiSubmitShortUrl(ShortURLSubmission shortUrl, String? apiKey, String? serverUrl, String apiVersion) async { try { final response = @@ -18,7 +19,7 @@ FutureOr> apiSubmitShortUrl(ShortURLSubmission shortUrl, if (response.statusCode == 200) { // get returned short url var jsonBody = jsonDecode(response.body); - return left(jsonBody["shortUrl"]); + return left(ShortURL.fromJson(jsonBody)); } else { try { var jsonBody = jsonDecode(response.body); diff --git a/lib/API/Methods/update_short_url.dart b/lib/API/Methods/update_short_url.dart new file mode 100644 index 0000000..904d22c --- /dev/null +++ b/lib/API/Methods/update_short_url.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:dartz/dartz.dart'; +import 'package:http/http.dart' as http; +import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; +import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; +import '../server_manager.dart'; + +/// Updates an existing short URL +FutureOr> apiUpdateShortUrl(ShortURLSubmission shortUrl, String? apiKey, String? serverUrl, String apiVersion) async { + String shortCode = shortUrl.customSlug ?? ""; + if (shortCode == "") { + return right(RequestFailure(0, "Missing short code")); + } + Map shortUrlData = shortUrl.toJson(); + shortUrlData.remove("shortCode"); + shortUrlData.remove("shortUrl"); + try { + final response = await http.patch(Uri.parse("$serverUrl/rest/v$apiVersion/short-urls/$shortCode"), + headers: { + "X-Api-Key": apiKey ?? "", + }, + body: jsonEncode(shortUrlData)); + + if (response.statusCode == 200) { + // get returned short url + var jsonBody = jsonDecode(response.body); + return left(ShortURL.fromJson(jsonBody)); + } else { + try { + var jsonBody = jsonDecode(response.body); + return right(ApiFailure( + type: jsonBody["type"], + detail: jsonBody["detail"], + title: jsonBody["title"], + status: jsonBody["status"], + invalidElements: jsonBody["invalidElements"])); + } catch (resErr) { + return right(RequestFailure(response.statusCode, resErr.toString())); + } + } + } catch (reqErr) { + return right(RequestFailure(0, reqErr.toString())); + } +} \ No newline at end of file diff --git a/lib/API/server_manager.dart b/lib/API/server_manager.dart index 9dd39a0..624b2d2 100644 --- a/lib/API/server_manager.dart +++ b/lib/API/server_manager.dart @@ -10,6 +10,7 @@ import 'package:shlink_app/API/Methods/get_recent_short_urls.dart'; import 'package:shlink_app/API/Methods/get_server_health.dart'; import 'package:shlink_app/API/Methods/get_shlink_stats.dart'; import 'package:shlink_app/API/Methods/get_short_urls.dart'; +import 'package:shlink_app/API/Methods/update_short_url.dart'; import 'Methods/delete_short_url.dart'; import 'Methods/submit_short_url.dart'; @@ -100,11 +101,16 @@ class ServerManager { } /// Saves a new short URL to the server - FutureOr> submitShortUrl( + FutureOr> submitShortUrl( ShortURLSubmission shortUrl) async { return apiSubmitShortUrl(shortUrl, apiKey, serverUrl, apiVersion); } + FutureOr> updateShortUrl( + ShortURLSubmission shortUrl) async { + return apiUpdateShortUrl(shortUrl, apiKey, serverUrl, apiVersion); + } + /// Deletes a short URL from the server, identified by its slug FutureOr> deleteShortUrl(String shortCode) async { return apiDeleteShortUrl(shortCode, apiKey, serverUrl, apiVersion); diff --git a/lib/views/short_url_edit_view.dart b/lib/views/short_url_edit_view.dart index cd1a14b..dcc3e58 100644 --- a/lib/views/short_url_edit_view.dart +++ b/lib/views/short_url_edit_view.dart @@ -1,11 +1,15 @@ +import 'package:dartz/dartz.dart' as dartz; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; import 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart'; import 'package:shlink_app/API/server_manager.dart'; import '../globals.dart' as globals; class ShortURLEditView extends StatefulWidget { - const ShortURLEditView({super.key}); + const ShortURLEditView({super.key, this.shortUrl}); + + final ShortURL? shortUrl; @override State createState() => _ShortURLEditViewState(); @@ -23,6 +27,8 @@ class _ShortURLEditViewState extends State bool forwardQuery = true; bool copyToClipboard = true; + bool disableSlugEditor = false; + String longUrlError = ""; String randomSlugLengthError = ""; @@ -36,6 +42,7 @@ class _ShortURLEditViewState extends State vsync: this, duration: const Duration(milliseconds: 500), ); + loadExistingUrl(); super.initState(); } @@ -48,6 +55,19 @@ class _ShortURLEditViewState extends State super.dispose(); } + void loadExistingUrl() { + if (widget.shortUrl != null) { + longUrlController.text = widget.shortUrl!.longUrl; + isCrawlable = widget.shortUrl!.crawlable; + // for some reason this attribute is not returned by the api + forwardQuery = true; + titleController.text = widget.shortUrl!.title ?? ""; + customSlugController.text = widget.shortUrl!.shortCode; + disableSlugEditor = true; + randomSlug = false; + } + } + void _submitShortUrl() async { var newSubmission = ShortURLSubmission( longUrl: longUrlController.text, @@ -62,7 +82,12 @@ class _ShortURLEditViewState extends State : null, shortCodeLength: randomSlug ? int.parse(randomSlugLengthController.text) : null); - var response = await globals.serverManager.submitShortUrl(newSubmission); + dartz.Either response; + if (widget.shortUrl != null) { + response = await globals.serverManager.updateShortUrl(newSubmission); + } else { + response = await globals.serverManager.submitShortUrl(newSubmission); + } response.fold((l) async { setState(() { @@ -70,7 +95,7 @@ class _ShortURLEditViewState extends State }); if (copyToClipboard) { - await Clipboard.setData(ClipboardData(text: l)); + await Clipboard.setData(ClipboardData(text: l.shortUrl)); final snackBar = SnackBar( content: const Text("Copied to clipboard!"), backgroundColor: Colors.green[400], @@ -83,7 +108,7 @@ class _ShortURLEditViewState extends State behavior: SnackBarBehavior.floating); ScaffoldMessenger.of(context).showSnackBar(snackBar); } - Navigator.pop(context); + Navigator.pop(context, l); return true; }, (r) { @@ -143,6 +168,7 @@ class _ShortURLEditViewState extends State children: [ Expanded( child: TextField( + enabled: !disableSlugEditor, controller: customSlugController, style: TextStyle( color: randomSlug @@ -182,7 +208,7 @@ class _ShortURLEditViewState extends State parent: _customSlugDiceAnimationController, curve: Curves.easeInOutExpo)), child: IconButton( - onPressed: () { + onPressed: disableSlugEditor ? null : () { if (randomSlug) { _customSlugDiceAnimationController.reverse( from: 1); diff --git a/lib/views/url_detail_view.dart b/lib/views/url_detail_view.dart index 7efcc4d..b3d7082 100644 --- a/lib/views/url_detail_view.dart +++ b/lib/views/url_detail_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; import 'package:intl/intl.dart'; import 'package:shlink_app/API/server_manager.dart'; +import 'package:shlink_app/views/short_url_edit_view.dart'; import 'package:shlink_app/widgets/url_tags_list_widget.dart'; import '../globals.dart' as globals; @@ -15,6 +16,16 @@ class URLDetailView extends StatefulWidget { } class _URLDetailViewState extends State { + + ShortURL shortURL = ShortURL.empty(); + @override + void initState() { + super.initState(); + setState(() { + shortURL = widget.shortURL; + }); + } + Future showDeletionConfirmation() { return showDialog( context: context, @@ -27,7 +38,7 @@ class _URLDetailViewState extends State { const Text("You're about to delete"), const SizedBox(height: 4), Text( - widget.shortURL.title ?? widget.shortURL.shortCode, + shortURL.title ?? shortURL.shortCode, style: const TextStyle(fontStyle: FontStyle.italic), ), const SizedBox(height: 4), @@ -42,11 +53,11 @@ class _URLDetailViewState extends State { TextButton( onPressed: () async { var response = await globals.serverManager - .deleteShortUrl(widget.shortURL.shortCode); + .deleteShortUrl(shortURL.shortCode); response.fold((l) { Navigator.pop(context); - Navigator.pop(context, "reload"); + Navigator.pop(context); final snackBar = SnackBar( content: const Text("Short URL deleted!"), @@ -84,9 +95,21 @@ class _URLDetailViewState extends State { body: CustomScrollView( slivers: [ SliverAppBar.medium( - title: Text(widget.shortURL.title ?? widget.shortURL.shortCode, + title: Text(shortURL.title ?? shortURL.shortCode, style: const TextStyle(fontWeight: FontWeight.bold)), actions: [ + IconButton( + onPressed: () async { + ShortURL updatedUrl = await Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ShortURLEditView(shortUrl: shortURL))); + setState(() { + shortURL = updatedUrl; + }); + }, + icon: const Icon( + Icons.edit + ) + ), IconButton( onPressed: () { showDeletionConfirmation(); @@ -100,56 +123,56 @@ class _URLDetailViewState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16.0, right: 16.0), - child: UrlTagsListWidget(tags: widget.shortURL.tags) + child: UrlTagsListWidget(tags: shortURL.tags) ), ), - _ListCell(title: "Short Code", content: widget.shortURL.shortCode), - _ListCell(title: "Short URL", content: widget.shortURL.shortUrl), - _ListCell(title: "Long URL", content: widget.shortURL.longUrl), + _ListCell(title: "Short Code", content: shortURL.shortCode), + _ListCell(title: "Short URL", content: shortURL.shortUrl), + _ListCell(title: "Long URL", content: shortURL.longUrl), _ListCell( title: "iOS", - content: widget.shortURL.deviceLongUrls.ios, + content: shortURL.deviceLongUrls.ios, sub: true), _ListCell( title: "Android", - content: widget.shortURL.deviceLongUrls.android, + content: shortURL.deviceLongUrls.android, sub: true), _ListCell( title: "Desktop", - content: widget.shortURL.deviceLongUrls.desktop, + content: shortURL.deviceLongUrls.desktop, sub: true), _ListCell( - title: "Creation Date", content: widget.shortURL.dateCreated), + title: "Creation Date", content: shortURL.dateCreated), const _ListCell(title: "Visits", content: ""), _ListCell( title: "Total", - content: widget.shortURL.visitsSummary.total, + content: shortURL.visitsSummary.total, sub: true), _ListCell( title: "Non-Bots", - content: widget.shortURL.visitsSummary.nonBots, + content: shortURL.visitsSummary.nonBots, sub: true), _ListCell( title: "Bots", - content: widget.shortURL.visitsSummary.bots, + content: shortURL.visitsSummary.bots, sub: true), const _ListCell(title: "Meta", content: ""), _ListCell( title: "Valid Since", - content: widget.shortURL.meta.validSince, + content: shortURL.meta.validSince, sub: true), _ListCell( title: "Valid Until", - content: widget.shortURL.meta.validUntil, + content: shortURL.meta.validUntil, sub: true), _ListCell( title: "Max Visits", - content: widget.shortURL.meta.maxVisits, + content: shortURL.meta.maxVisits, sub: true), - _ListCell(title: "Domain", content: widget.shortURL.domain), + _ListCell(title: "Domain", content: shortURL.domain), _ListCell( title: "Crawlable", - content: widget.shortURL.crawlable, + content: shortURL.crawlable, last: true) ], ), diff --git a/lib/views/url_list_view.dart b/lib/views/url_list_view.dart index 5ddd597..aee1da5 100644 --- a/lib/views/url_list_view.dart +++ b/lib/views/url_list_view.dart @@ -194,12 +194,11 @@ class _ShortURLCellState extends State { Widget build(BuildContext context) { return GestureDetector( onTap: () async { - final result = await Navigator.of(context).push(MaterialPageRoute( - builder: (context) => URLDetailView(shortURL: widget.shortURL))); - - if (result == "reload") { - widget.reload(); - } + await Navigator.of(context).push(MaterialPageRoute( + builder: (context) => URLDetailView(shortURL: widget.shortURL))) + .then((a) => { + widget.reload() + }); }, child: Padding( padding: EdgeInsets.only(