FEATURE: add and remove tags from short URLs

This commit is contained in:
Adrian Baumgart 2024-07-26 22:52:44 +02:00
parent 202ab20747
commit 50ec0cb49f
No known key found for this signature in database
7 changed files with 409 additions and 6 deletions

View File

@ -0,0 +1,20 @@
import 'package:shlink_app/API/Classes/ShortURL/visits_summary.dart';
/// Tag with stats data
class TagWithStats {
/// Tag name
String tag;
/// Amount of short URLs using this tag
int shortUrlsCount;
/// visits summary for tag
VisitsSummary visitsSummary;
TagWithStats(this.tag, this.shortUrlsCount, this.visitsSummary);
TagWithStats.fromJson(Map<String, dynamic> json)
: tag = json["tag"] as String,
shortUrlsCount = json["shortUrlsCount"] as int,
visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]);
}

View File

@ -0,0 +1,68 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartz/dartz.dart';
import 'package:http/http.dart' as http;
import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart';
import '../server_manager.dart';
/// Gets all tags
FutureOr<Either<List<TagWithStats>, Failure>> apiGetTagsWithStats(
String? apiKey, String? serverUrl, String apiVersion) async {
var currentPage = 1;
var maxPages = 2;
List<TagWithStats> allTags = [];
Failure? error;
while (currentPage <= maxPages) {
final response =
await _getTagsWithStatsPage(currentPage, apiKey, serverUrl, apiVersion);
response.fold((l) {
allTags.addAll(l.tags);
maxPages = l.totalPages;
currentPage++;
}, (r) {
maxPages = 0;
error = r;
});
}
if (error == null) {
return left(allTags);
} else {
return right(error!);
}
}
/// Gets all tags from a specific page
FutureOr<Either<TagsWithStatsPageResponse, Failure>> _getTagsWithStatsPage(
int page, String? apiKey, String? serverUrl, String apiVersion) async {
try {
final response = await http.get(
Uri.parse("$serverUrl/rest/v$apiVersion/tags/stats?page=$page"),
headers: {
"X-Api-Key": apiKey ?? "",
});
if (response.statusCode == 200) {
var jsonResponse = jsonDecode(response.body);
var pagesCount = jsonResponse["tags"]["pagination"]["pagesCount"] as int;
List<TagWithStats> tags =
(jsonResponse["tags"]["data"] as List<dynamic>).map((e) {
return TagWithStats.fromJson(e);
}).toList();
return left(TagsWithStatsPageResponse(tags, pagesCount));
} else {
try {
var jsonBody = jsonDecode(response.body);
return right(ApiFailure(
type: jsonBody["type"],
detail: jsonBody["detail"],
title: jsonBody["title"],
status: jsonBody["status"]));
} catch (resErr) {
return right(RequestFailure(response.statusCode, resErr.toString()));
}
}
} catch (reqErr) {
return right(RequestFailure(0, reqErr.toString()));
}
}

View File

@ -7,12 +7,14 @@ import 'package:shlink_app/API/Classes/ShlinkStats/shlink_stats.dart';
import 'package:shlink_app/API/Classes/ShortURL/RedirectRule/redirect_rule.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/Tag/tag_with_stats.dart';
import 'package:shlink_app/API/Methods/connect.dart';
import 'package:shlink_app/API/Methods/get_recent_short_urls.dart';
import 'package:shlink_app/API/Methods/get_redirect_rules.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/get_tags_with_stats.dart';
import 'package:shlink_app/API/Methods/set_redirect_rules.dart';
import 'package:shlink_app/API/Methods/update_short_url.dart';
@ -181,6 +183,11 @@ class ServerManager {
return apiGetShortUrls(apiKey, serverUrl, apiVersion);
}
/// Gets all tags from the server
FutureOr<Either<List<TagWithStats>, Failure>> getTags() async {
return apiGetTagsWithStats(apiKey, serverUrl, apiVersion);
}
/// Gets statistics about the Shlink instance
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
return apiGetShlinkStats(apiKey, serverUrl, apiVersion);
@ -234,6 +241,14 @@ class ShortURLPageResponse {
ShortURLPageResponse(this.urls, this.totalPages);
}
/// Server response data type about a page of tags from the server
class TagsWithStatsPageResponse {
List<TagWithStats> tags;
int totalPages;
TagsWithStatsPageResponse(this.tags, this.totalPages);
}
/// Server response data type about the health status of the server
class ServerHealthResponse {
String status;

View File

@ -18,7 +18,7 @@ class GlobalTheme {
canvasColor: colorScheme.surface,
scaffoldBackgroundColor: colorScheme.surface,
highlightColor: Colors.transparent,
dividerColor: colorScheme.outline,
dividerColor: colorScheme.shadow,
focusColor: focusColor,
useMaterial3: true,
appBarTheme: AppBarTheme(
@ -38,7 +38,8 @@ class GlobalTheme {
tertiary: Colors.grey[300],
onTertiary: Colors.grey[700],
surfaceContainer: (Colors.grey[100])!,
outline: (Colors.grey[400])!,
outline: (Colors.grey[500])!,
shadow: (Colors.grey[300])!,
error: (Colors.red[400])!,
onError: Colors.white,
surface: Color(0xFFFAFBFB),
@ -57,7 +58,8 @@ class GlobalTheme {
onSurfaceVariant: Colors.grey[400],
tertiary: Colors.grey[900],
onTertiary: Colors.grey,
outline: (Colors.grey[800])!,
outline: (Colors.grey[700])!,
shadow: (Colors.grey[800])!,
error: (Colors.red[400])!,
onError: Colors.white,
onPrimary: Colors.white,

View File

@ -93,7 +93,7 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
child: isSaving
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3))
child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white))
: const Icon(Icons.save))
],
),

View File

@ -1,10 +1,14 @@
import 'package:dartz/dartz.dart' as dartz;
import 'package:dynamic_color/dynamic_color.dart';
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 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/util/string_to_color.dart';
import 'package:shlink_app/views/tag_selector_view.dart';
import 'package:shlink_app/widgets/url_tags_list_widget.dart';
import '../globals.dart' as globals;
class ShortURLEditView extends StatefulWidget {
@ -23,6 +27,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
final customSlugController = TextEditingController();
final titleController = TextEditingController();
final randomSlugLengthController = TextEditingController(text: "5");
List<String> tags = [];
bool randomSlug = true;
bool isCrawlable = true;
@ -64,6 +69,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
if (widget.shortUrl != null) {
longUrlController.text = widget.shortUrl!.longUrl;
isCrawlable = widget.shortUrl!.crawlable;
tags = widget.shortUrl!.tags;
// for some reason this attribute is not returned by the api
forwardQuery = true;
titleController.text = widget.shortUrl!.title ?? "";
@ -104,7 +110,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
void _submitShortUrl() async {
var newSubmission = ShortURLSubmission(
longUrl: longUrlController.text,
tags: [],
tags: tags,
crawlable: isCrawlable,
forwardQuery: forwardQuery,
findIfExists: true,
@ -280,6 +286,57 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
],
)),
),
GestureDetector(
onTap: () async {
List<String>? selectedTags = await Navigator.of(context).
push(MaterialPageRoute(
builder: (context) =>
TagSelectorView(alreadySelectedTags: tags)));
if (selectedTags != null) {
setState(() {
tags = selectedTags;
});
}
},
child: InputDecorator(
isEmpty: tags.isEmpty,
decoration: const InputDecoration(
border: OutlineInputBorder(),
label: Row(
children: [
Icon(Icons.label_outline),
SizedBox(width: 8),
Text("Tags")
],
)),
child: Wrap(
runSpacing: 8,
spacing: 8,
children: tags.map((tag) {
var boxColor = stringToColor(tag)
.harmonizeWith(Theme.of(context).colorScheme.
primary);
var textColor = boxColor.computeLuminance() < 0.5
? Colors.white
: Colors.black;
return InputChip(
label: Text(tag, style: TextStyle(
color: textColor
)),
backgroundColor: boxColor,
deleteIcon: Icon(Icons.close,
size: 18,
color: textColor),
onDeleted: () {
setState(() {
tags.remove(tag);
});
},
);
}).toList(),
)
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -335,7 +392,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
child: isSaving
? const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3))
child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white))
: const Icon(Icons.save)),
);
}

View File

@ -0,0 +1,241 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:shlink_app/API/Classes/Tag/tag_with_stats.dart';
import 'package:shlink_app/util/build_api_error_snackbar.dart';
import 'package:shlink_app/util/string_to_color.dart';
import '../globals.dart' as globals;
class TagSelectorView extends StatefulWidget {
const TagSelectorView({super.key, this.alreadySelectedTags = const []});
final List<String> alreadySelectedTags;
@override
State<TagSelectorView> createState() => _TagSelectorViewState();
}
class _TagSelectorViewState extends State<TagSelectorView> {
final FocusNode searchTagFocusNode = FocusNode();
final searchTagController = TextEditingController();
List<TagWithStats> availableTags = [];
List<TagWithStats> selectedTags = [];
List<TagWithStats> filteredTags = [];
bool tagsLoaded = false;
@override
void initState() {
super.initState();
selectedTags = [];
searchTagController.text = "";
filteredTags = [];
searchTagFocusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) => loadTags());
}
@override
void dispose() {
searchTagFocusNode.dispose();
searchTagController.dispose();
super.dispose();
}
Future<void> loadTags() async {
final response =
await globals.serverManager.getTags();
response.fold((l) {
List<TagWithStats> mappedAlreadySelectedTags =
widget.alreadySelectedTags.map((e) {
return l.firstWhere((t) => t.tag == e);
}).toList();
setState(() {
availableTags = (l + [... mappedAlreadySelectedTags]).toSet().toList();
selectedTags = [...mappedAlreadySelectedTags];
filteredTags = availableTags;
tagsLoaded = true;
});
_sortLists();
return true;
}, (r) {
ScaffoldMessenger.of(context).showSnackBar(
buildApiErrorSnackbar(r, context)
);
return false;
});
}
void _sortLists() {
setState(() {
availableTags.sort();
filteredTags.sort();
});
}
void _searchTextChanged(String text) {
if (text == "") {
setState(() {
filteredTags = availableTags;
});
} else {
setState(() {
filteredTags = availableTags.where((t) => t.tag.toLowerCase()
.contains(text.toLowerCase())).toList();
});
}
_sortLists();
}
void _addNewTag(String tag) {
if (tag != "" && !availableTags.contains(tag)) {
TagWithStats _tagWithStats = availableTags.firstWhere((e) => e.tag == tag);
setState(() {
availableTags.add(_tagWithStats);
selectedTags.add(_tagWithStats);
_searchTextChanged(tag);
});
_sortLists();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: searchTagController,
focusNode: searchTagFocusNode,
onChanged: _searchTextChanged,
decoration: const InputDecoration(
hintText: "Start typing...",
border: InputBorder.none,
icon: Icon(Icons.label_outline),
),
),
actions: [
IconButton(
onPressed: () {
Navigator.pop(context, selectedTags.map((t) => t.tag).toList());
},
icon: const Icon(Icons.check),
)
],
),
body: CustomScrollView(
slivers: [
if (!tagsLoaded)
const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 3),
),
),
)
else if (tagsLoaded && availableTags.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 50),
child: Column(
children: [
const Text(
"No Tags",
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Start typing to add new tags!',
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.onSecondary),
),
)
],
))))
else
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
bool _isSelected = selectedTags.contains(filteredTags[index]);
TagWithStats _tag = filteredTags[index];
return GestureDetector(
onTap: () {
if (_isSelected) {
setState(() {
selectedTags.remove(_tag);
});
} else {
setState(() {
selectedTags.add(_tag);
});
}
},
child: Container(
padding: const EdgeInsets.only(left: 16, right: 16,
top: 16, bottom: 16),
decoration: BoxDecoration(
color: _isSelected ? Theme.of(context).colorScheme.primary : null,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Wrap(
spacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: stringToColor(_tag.tag)
.harmonizeWith(Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(15)
),
),
Text(_tag.tag)
],
),
Text("${_tag.shortUrlsCount} short URL"
"${_tag.shortUrlsCount == 1 ? "" : "s"}",
style: TextStyle(
color: Theme.of(context).colorScheme.onTertiary,
fontSize: 12
),)
],
)
)
);
}, childCount: filteredTags.length
),
),
if (searchTagController.text != "" &&
!availableTags.contains(searchTagController.text))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8,
left: 16, right: 16),
child: Center(
child: TextButton(
onPressed: () {
_addNewTag(searchTagController.text);
},
child: Text('Add tag "${searchTagController.text}"'),
),
),
),
)
],
)
);
}
}