mirror of
https://github.com/rainloreley/shlink-manager.git
synced 2024-11-21 09:33:01 +01:00
FEATURE: add and remove tags from short URLs
This commit is contained in:
parent
202ab20747
commit
50ec0cb49f
|
@ -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"]);
|
||||
}
|
|
@ -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()));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
],
|
||||
),
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}"'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user