diff --git a/lib/API/Classes/Tag/tag_with_stats.dart b/lib/API/Classes/Tag/tag_with_stats.dart new file mode 100644 index 0000000..176c7a3 --- /dev/null +++ b/lib/API/Classes/Tag/tag_with_stats.dart @@ -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 json) + : tag = json["tag"] as String, + shortUrlsCount = json["shortUrlsCount"] as int, + visitsSummary = VisitsSummary.fromJson(json["visitsSummary"]); +} \ No newline at end of file diff --git a/lib/API/Methods/get_tags_with_stats.dart b/lib/API/Methods/get_tags_with_stats.dart new file mode 100644 index 0000000..b5ff986 --- /dev/null +++ b/lib/API/Methods/get_tags_with_stats.dart @@ -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, Failure>> apiGetTagsWithStats( + String? apiKey, String? serverUrl, String apiVersion) async { + var currentPage = 1; + var maxPages = 2; + List 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> _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 tags = + (jsonResponse["tags"]["data"] as List).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())); + } +} diff --git a/lib/API/server_manager.dart b/lib/API/server_manager.dart index 9c485e7..d894dae 100644 --- a/lib/API/server_manager.dart +++ b/lib/API/server_manager.dart @@ -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, Failure>> getTags() async { + return apiGetTagsWithStats(apiKey, serverUrl, apiVersion); + } + /// Gets statistics about the Shlink instance FutureOr> 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 tags; + int totalPages; + + TagsWithStatsPageResponse(this.tags, this.totalPages); +} + /// Server response data type about the health status of the server class ServerHealthResponse { String status; diff --git a/lib/global_theme.dart b/lib/global_theme.dart index 5895300..62e1f9b 100644 --- a/lib/global_theme.dart +++ b/lib/global_theme.dart @@ -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, diff --git a/lib/views/redirect_rules_detail_view.dart b/lib/views/redirect_rules_detail_view.dart index 2135f5d..226582c 100644 --- a/lib/views/redirect_rules_detail_view.dart +++ b/lib/views/redirect_rules_detail_view.dart @@ -93,7 +93,7 @@ class _RedirectRulesDetailViewState extends State { child: isSaving ? const Padding( padding: EdgeInsets.all(16), - child: CircularProgressIndicator(strokeWidth: 3)) + child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white)) : const Icon(Icons.save)) ], ), diff --git a/lib/views/short_url_edit_view.dart b/lib/views/short_url_edit_view.dart index f650510..04201b5 100644 --- a/lib/views/short_url_edit_view.dart +++ b/lib/views/short_url_edit_view.dart @@ -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 final customSlugController = TextEditingController(); final titleController = TextEditingController(); final randomSlugLengthController = TextEditingController(text: "5"); + List tags = []; bool randomSlug = true; bool isCrawlable = true; @@ -64,6 +69,7 @@ class _ShortURLEditViewState extends State 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 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 ], )), ), + GestureDetector( + onTap: () async { + List? 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 child: isSaving ? const Padding( padding: EdgeInsets.all(16), - child: CircularProgressIndicator(strokeWidth: 3)) + child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white)) : const Icon(Icons.save)), ); } diff --git a/lib/views/tag_selector_view.dart b/lib/views/tag_selector_view.dart new file mode 100644 index 0000000..4e47940 --- /dev/null +++ b/lib/views/tag_selector_view.dart @@ -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 alreadySelectedTags; + + @override + State createState() => _TagSelectorViewState(); +} + +class _TagSelectorViewState extends State { + + final FocusNode searchTagFocusNode = FocusNode(); + final searchTagController = TextEditingController(); + + List availableTags = []; + List selectedTags = []; + List 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 loadTags() async { + final response = + await globals.serverManager.getTags(); + response.fold((l) { + + List 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}"'), + ), + ), + ), + ) + ], + ) + ); + } +}