edit existing short urls

This commit is contained in:
Adrian Baumgart 2024-01-28 22:55:28 +01:00
parent d00ba4b4e9
commit 4172c968c4
No known key found for this signature in database
8 changed files with 148 additions and 35 deletions

View File

@ -22,13 +22,14 @@ Shlink Manager is an app for Android to see and manage all shortened URLs create
✅ Display tags<br/> ✅ Display tags<br/>
✅ Display QR code<br/> ✅ Display QR code<br/>
✅ Dark mode support<br/> ✅ Dark mode support<br/>
✅ Edit existing short URLs<br/>
## 🔨 To Do ## 🔨 To Do
- [ ] Edit existing short URLs
- [ ] Add support for iOS (maybe in the future) - [ ] Add support for iOS (maybe in the future)
- [ ] add tags - [ ] add tags
- [ ] specify individual long URLs per device - [ ] specify individual long URLs per device
- [ ] improve app icon - [ ] improve app icon
- [ ] Refactor code
- [ ] ...and more - [ ] ...and more
## 💻 Development ## 💻 Development

View File

@ -63,4 +63,16 @@ class ShortURL {
domain = json["domain"], domain = json["domain"],
title = json["title"], title = json["title"],
crawlable = json["crawlable"]; 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;
} }

View File

@ -2,11 +2,12 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http; 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 'package:shlink_app/API/Classes/ShortURLSubmission/short_url_submission.dart';
import '../server_manager.dart'; import '../server_manager.dart';
/// Submits a short URL to a server for it to be added /// Submits a short URL to a server for it to be added
FutureOr<Either<String, Failure>> apiSubmitShortUrl(ShortURLSubmission shortUrl, FutureOr<Either<ShortURL, Failure>> apiSubmitShortUrl(ShortURLSubmission shortUrl,
String? apiKey, String? serverUrl, String apiVersion) async { String? apiKey, String? serverUrl, String apiVersion) async {
try { try {
final response = final response =
@ -18,7 +19,7 @@ FutureOr<Either<String, Failure>> apiSubmitShortUrl(ShortURLSubmission shortUrl,
if (response.statusCode == 200) { if (response.statusCode == 200) {
// get returned short url // get returned short url
var jsonBody = jsonDecode(response.body); var jsonBody = jsonDecode(response.body);
return left(jsonBody["shortUrl"]); return left(ShortURL.fromJson(jsonBody));
} else { } else {
try { try {
var jsonBody = jsonDecode(response.body); var jsonBody = jsonDecode(response.body);

View File

@ -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<Either<ShortURL, Failure>> apiUpdateShortUrl(ShortURLSubmission shortUrl, String? apiKey, String? serverUrl, String apiVersion) async {
String shortCode = shortUrl.customSlug ?? "";
if (shortCode == "") {
return right(RequestFailure(0, "Missing short code"));
}
Map<String, dynamic> 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()));
}
}

View File

@ -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_server_health.dart';
import 'package:shlink_app/API/Methods/get_shlink_stats.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/get_short_urls.dart';
import 'package:shlink_app/API/Methods/update_short_url.dart';
import 'Methods/delete_short_url.dart'; import 'Methods/delete_short_url.dart';
import 'Methods/submit_short_url.dart'; import 'Methods/submit_short_url.dart';
@ -100,11 +101,16 @@ class ServerManager {
} }
/// Saves a new short URL to the server /// Saves a new short URL to the server
FutureOr<Either<String, Failure>> submitShortUrl( FutureOr<Either<ShortURL, Failure>> submitShortUrl(
ShortURLSubmission shortUrl) async { ShortURLSubmission shortUrl) async {
return apiSubmitShortUrl(shortUrl, apiKey, serverUrl, apiVersion); return apiSubmitShortUrl(shortUrl, apiKey, serverUrl, apiVersion);
} }
FutureOr<Either<ShortURL, Failure>> updateShortUrl(
ShortURLSubmission shortUrl) async {
return apiUpdateShortUrl(shortUrl, apiKey, serverUrl, apiVersion);
}
/// Deletes a short URL from the server, identified by its slug /// Deletes a short URL from the server, identified by its slug
FutureOr<Either<String, Failure>> deleteShortUrl(String shortCode) async { FutureOr<Either<String, Failure>> deleteShortUrl(String shortCode) async {
return apiDeleteShortUrl(shortCode, apiKey, serverUrl, apiVersion); return apiDeleteShortUrl(shortCode, apiKey, serverUrl, apiVersion);

View File

@ -1,11 +1,15 @@
import 'package:dartz/dartz.dart' as dartz;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/Classes/ShortURLSubmission/short_url_submission.dart';
import 'package:shlink_app/API/server_manager.dart'; import 'package:shlink_app/API/server_manager.dart';
import '../globals.dart' as globals; import '../globals.dart' as globals;
class ShortURLEditView extends StatefulWidget { class ShortURLEditView extends StatefulWidget {
const ShortURLEditView({super.key}); const ShortURLEditView({super.key, this.shortUrl});
final ShortURL? shortUrl;
@override @override
State<ShortURLEditView> createState() => _ShortURLEditViewState(); State<ShortURLEditView> createState() => _ShortURLEditViewState();
@ -23,6 +27,8 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
bool forwardQuery = true; bool forwardQuery = true;
bool copyToClipboard = true; bool copyToClipboard = true;
bool disableSlugEditor = false;
String longUrlError = ""; String longUrlError = "";
String randomSlugLengthError = ""; String randomSlugLengthError = "";
@ -36,6 +42,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
); );
loadExistingUrl();
super.initState(); super.initState();
} }
@ -48,6 +55,19 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
super.dispose(); 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 { void _submitShortUrl() async {
var newSubmission = ShortURLSubmission( var newSubmission = ShortURLSubmission(
longUrl: longUrlController.text, longUrl: longUrlController.text,
@ -62,7 +82,12 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
: null, : null,
shortCodeLength: shortCodeLength:
randomSlug ? int.parse(randomSlugLengthController.text) : null); randomSlug ? int.parse(randomSlugLengthController.text) : null);
var response = await globals.serverManager.submitShortUrl(newSubmission); dartz.Either<ShortURL, Failure> response;
if (widget.shortUrl != null) {
response = await globals.serverManager.updateShortUrl(newSubmission);
} else {
response = await globals.serverManager.submitShortUrl(newSubmission);
}
response.fold((l) async { response.fold((l) async {
setState(() { setState(() {
@ -70,7 +95,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
}); });
if (copyToClipboard) { if (copyToClipboard) {
await Clipboard.setData(ClipboardData(text: l)); await Clipboard.setData(ClipboardData(text: l.shortUrl));
final snackBar = SnackBar( final snackBar = SnackBar(
content: const Text("Copied to clipboard!"), content: const Text("Copied to clipboard!"),
backgroundColor: Colors.green[400], backgroundColor: Colors.green[400],
@ -83,7 +108,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
behavior: SnackBarBehavior.floating); behavior: SnackBarBehavior.floating);
ScaffoldMessenger.of(context).showSnackBar(snackBar); ScaffoldMessenger.of(context).showSnackBar(snackBar);
} }
Navigator.pop(context); Navigator.pop(context, l);
return true; return true;
}, (r) { }, (r) {
@ -143,6 +168,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
enabled: !disableSlugEditor,
controller: customSlugController, controller: customSlugController,
style: TextStyle( style: TextStyle(
color: randomSlug color: randomSlug
@ -182,7 +208,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
parent: _customSlugDiceAnimationController, parent: _customSlugDiceAnimationController,
curve: Curves.easeInOutExpo)), curve: Curves.easeInOutExpo)),
child: IconButton( child: IconButton(
onPressed: () { onPressed: disableSlugEditor ? null : () {
if (randomSlug) { if (randomSlug) {
_customSlugDiceAnimationController.reverse( _customSlugDiceAnimationController.reverse(
from: 1); from: 1);

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/ShortURL/short_url.dart'; import 'package:shlink_app/API/Classes/ShortURL/short_url.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:shlink_app/API/server_manager.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 'package:shlink_app/widgets/url_tags_list_widget.dart';
import '../globals.dart' as globals; import '../globals.dart' as globals;
@ -15,6 +16,16 @@ class URLDetailView extends StatefulWidget {
} }
class _URLDetailViewState extends State<URLDetailView> { class _URLDetailViewState extends State<URLDetailView> {
ShortURL shortURL = ShortURL.empty();
@override
void initState() {
super.initState();
setState(() {
shortURL = widget.shortURL;
});
}
Future showDeletionConfirmation() { Future showDeletionConfirmation() {
return showDialog( return showDialog(
context: context, context: context,
@ -27,7 +38,7 @@ class _URLDetailViewState extends State<URLDetailView> {
const Text("You're about to delete"), const Text("You're about to delete"),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.shortURL.title ?? widget.shortURL.shortCode, shortURL.title ?? shortURL.shortCode,
style: const TextStyle(fontStyle: FontStyle.italic), style: const TextStyle(fontStyle: FontStyle.italic),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -42,11 +53,11 @@ class _URLDetailViewState extends State<URLDetailView> {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
var response = await globals.serverManager var response = await globals.serverManager
.deleteShortUrl(widget.shortURL.shortCode); .deleteShortUrl(shortURL.shortCode);
response.fold((l) { response.fold((l) {
Navigator.pop(context); Navigator.pop(context);
Navigator.pop(context, "reload"); Navigator.pop(context);
final snackBar = SnackBar( final snackBar = SnackBar(
content: const Text("Short URL deleted!"), content: const Text("Short URL deleted!"),
@ -84,9 +95,21 @@ class _URLDetailViewState extends State<URLDetailView> {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar.medium( SliverAppBar.medium(
title: Text(widget.shortURL.title ?? widget.shortURL.shortCode, title: Text(shortURL.title ?? shortURL.shortCode,
style: const TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [ 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( IconButton(
onPressed: () { onPressed: () {
showDeletionConfirmation(); showDeletionConfirmation();
@ -100,56 +123,56 @@ class _URLDetailViewState extends State<URLDetailView> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0), 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 Code", content: shortURL.shortCode),
_ListCell(title: "Short URL", content: widget.shortURL.shortUrl), _ListCell(title: "Short URL", content: shortURL.shortUrl),
_ListCell(title: "Long URL", content: widget.shortURL.longUrl), _ListCell(title: "Long URL", content: shortURL.longUrl),
_ListCell( _ListCell(
title: "iOS", title: "iOS",
content: widget.shortURL.deviceLongUrls.ios, content: shortURL.deviceLongUrls.ios,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Android", title: "Android",
content: widget.shortURL.deviceLongUrls.android, content: shortURL.deviceLongUrls.android,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Desktop", title: "Desktop",
content: widget.shortURL.deviceLongUrls.desktop, content: shortURL.deviceLongUrls.desktop,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Creation Date", content: widget.shortURL.dateCreated), title: "Creation Date", content: shortURL.dateCreated),
const _ListCell(title: "Visits", content: ""), const _ListCell(title: "Visits", content: ""),
_ListCell( _ListCell(
title: "Total", title: "Total",
content: widget.shortURL.visitsSummary.total, content: shortURL.visitsSummary.total,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Non-Bots", title: "Non-Bots",
content: widget.shortURL.visitsSummary.nonBots, content: shortURL.visitsSummary.nonBots,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Bots", title: "Bots",
content: widget.shortURL.visitsSummary.bots, content: shortURL.visitsSummary.bots,
sub: true), sub: true),
const _ListCell(title: "Meta", content: ""), const _ListCell(title: "Meta", content: ""),
_ListCell( _ListCell(
title: "Valid Since", title: "Valid Since",
content: widget.shortURL.meta.validSince, content: shortURL.meta.validSince,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Valid Until", title: "Valid Until",
content: widget.shortURL.meta.validUntil, content: shortURL.meta.validUntil,
sub: true), sub: true),
_ListCell( _ListCell(
title: "Max Visits", title: "Max Visits",
content: widget.shortURL.meta.maxVisits, content: shortURL.meta.maxVisits,
sub: true), sub: true),
_ListCell(title: "Domain", content: widget.shortURL.domain), _ListCell(title: "Domain", content: shortURL.domain),
_ListCell( _ListCell(
title: "Crawlable", title: "Crawlable",
content: widget.shortURL.crawlable, content: shortURL.crawlable,
last: true) last: true)
], ],
), ),

View File

@ -194,12 +194,11 @@ class _ShortURLCellState extends State<ShortURLCell> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () async { onTap: () async {
final result = await Navigator.of(context).push(MaterialPageRoute( await Navigator.of(context).push(MaterialPageRoute(
builder: (context) => URLDetailView(shortURL: widget.shortURL))); builder: (context) => URLDetailView(shortURL: widget.shortURL)))
.then((a) => {
if (result == "reload") { widget.reload()
widget.reload(); });
}
}, },
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(