mirror of
https://github.com/rainloreley/shlink-manager.git
synced 2024-11-21 17:33:03 +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/RedirectRule/redirect_rule.dart';
|
||||||
import 'package:shlink_app/API/Classes/ShortURL/short_url.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/Classes/Tag/tag_with_stats.dart';
|
||||||
import 'package:shlink_app/API/Methods/connect.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_recent_short_urls.dart';
|
||||||
import 'package:shlink_app/API/Methods/get_redirect_rules.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_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/get_tags_with_stats.dart';
|
||||||
import 'package:shlink_app/API/Methods/set_redirect_rules.dart';
|
import 'package:shlink_app/API/Methods/set_redirect_rules.dart';
|
||||||
import 'package:shlink_app/API/Methods/update_short_url.dart';
|
import 'package:shlink_app/API/Methods/update_short_url.dart';
|
||||||
|
|
||||||
|
@ -181,6 +183,11 @@ class ServerManager {
|
||||||
return apiGetShortUrls(apiKey, serverUrl, apiVersion);
|
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
|
/// Gets statistics about the Shlink instance
|
||||||
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
|
FutureOr<Either<ShlinkStats, Failure>> getShlinkStats() async {
|
||||||
return apiGetShlinkStats(apiKey, serverUrl, apiVersion);
|
return apiGetShlinkStats(apiKey, serverUrl, apiVersion);
|
||||||
|
@ -234,6 +241,14 @@ class ShortURLPageResponse {
|
||||||
ShortURLPageResponse(this.urls, this.totalPages);
|
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
|
/// Server response data type about the health status of the server
|
||||||
class ServerHealthResponse {
|
class ServerHealthResponse {
|
||||||
String status;
|
String status;
|
||||||
|
|
|
@ -18,7 +18,7 @@ class GlobalTheme {
|
||||||
canvasColor: colorScheme.surface,
|
canvasColor: colorScheme.surface,
|
||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
dividerColor: colorScheme.outline,
|
dividerColor: colorScheme.shadow,
|
||||||
focusColor: focusColor,
|
focusColor: focusColor,
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
|
@ -38,7 +38,8 @@ class GlobalTheme {
|
||||||
tertiary: Colors.grey[300],
|
tertiary: Colors.grey[300],
|
||||||
onTertiary: Colors.grey[700],
|
onTertiary: Colors.grey[700],
|
||||||
surfaceContainer: (Colors.grey[100])!,
|
surfaceContainer: (Colors.grey[100])!,
|
||||||
outline: (Colors.grey[400])!,
|
outline: (Colors.grey[500])!,
|
||||||
|
shadow: (Colors.grey[300])!,
|
||||||
error: (Colors.red[400])!,
|
error: (Colors.red[400])!,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
surface: Color(0xFFFAFBFB),
|
surface: Color(0xFFFAFBFB),
|
||||||
|
@ -57,7 +58,8 @@ class GlobalTheme {
|
||||||
onSurfaceVariant: Colors.grey[400],
|
onSurfaceVariant: Colors.grey[400],
|
||||||
tertiary: Colors.grey[900],
|
tertiary: Colors.grey[900],
|
||||||
onTertiary: Colors.grey,
|
onTertiary: Colors.grey,
|
||||||
outline: (Colors.grey[800])!,
|
outline: (Colors.grey[700])!,
|
||||||
|
shadow: (Colors.grey[800])!,
|
||||||
error: (Colors.red[400])!,
|
error: (Colors.red[400])!,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
|
|
|
@ -93,7 +93,7 @@ class _RedirectRulesDetailViewState extends State<RedirectRulesDetailView> {
|
||||||
child: isSaving
|
child: isSaving
|
||||||
? const Padding(
|
? const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
child: CircularProgressIndicator(strokeWidth: 3))
|
child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white))
|
||||||
: const Icon(Icons.save))
|
: const Icon(Icons.save))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
import 'package:dartz/dartz.dart' as dartz;
|
import 'package:dartz/dartz.dart' as dartz;
|
||||||
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
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/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 'package:shlink_app/util/build_api_error_snackbar.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;
|
import '../globals.dart' as globals;
|
||||||
|
|
||||||
class ShortURLEditView extends StatefulWidget {
|
class ShortURLEditView extends StatefulWidget {
|
||||||
|
@ -23,6 +27,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
|
||||||
final customSlugController = TextEditingController();
|
final customSlugController = TextEditingController();
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final randomSlugLengthController = TextEditingController(text: "5");
|
final randomSlugLengthController = TextEditingController(text: "5");
|
||||||
|
List<String> tags = [];
|
||||||
|
|
||||||
bool randomSlug = true;
|
bool randomSlug = true;
|
||||||
bool isCrawlable = true;
|
bool isCrawlable = true;
|
||||||
|
@ -64,6 +69,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
|
||||||
if (widget.shortUrl != null) {
|
if (widget.shortUrl != null) {
|
||||||
longUrlController.text = widget.shortUrl!.longUrl;
|
longUrlController.text = widget.shortUrl!.longUrl;
|
||||||
isCrawlable = widget.shortUrl!.crawlable;
|
isCrawlable = widget.shortUrl!.crawlable;
|
||||||
|
tags = widget.shortUrl!.tags;
|
||||||
// for some reason this attribute is not returned by the api
|
// for some reason this attribute is not returned by the api
|
||||||
forwardQuery = true;
|
forwardQuery = true;
|
||||||
titleController.text = widget.shortUrl!.title ?? "";
|
titleController.text = widget.shortUrl!.title ?? "";
|
||||||
|
@ -104,7 +110,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
|
||||||
void _submitShortUrl() async {
|
void _submitShortUrl() async {
|
||||||
var newSubmission = ShortURLSubmission(
|
var newSubmission = ShortURLSubmission(
|
||||||
longUrl: longUrlController.text,
|
longUrl: longUrlController.text,
|
||||||
tags: [],
|
tags: tags,
|
||||||
crawlable: isCrawlable,
|
crawlable: isCrawlable,
|
||||||
forwardQuery: forwardQuery,
|
forwardQuery: forwardQuery,
|
||||||
findIfExists: true,
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
@ -335,7 +392,7 @@ class _ShortURLEditViewState extends State<ShortURLEditView>
|
||||||
child: isSaving
|
child: isSaving
|
||||||
? const Padding(
|
? const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
child: CircularProgressIndicator(strokeWidth: 3))
|
child: CircularProgressIndicator(strokeWidth: 3, color: Colors.white))
|
||||||
: const Icon(Icons.save)),
|
: 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