Skip to content

Commit d68bef0

Browse files
authored
Merge pull request #185 from Piwigo/feature/images_by_tags
Images by tags
2 parents c0b883c + 3c0c146 commit d68bef0

9 files changed

Lines changed: 669 additions & 46 deletions

File tree

lib/app.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:piwigo_ng/views/image/edit_image_page.dart';
1616
import 'package:piwigo_ng/views/image/image_favorites_page.dart';
1717
import 'package:piwigo_ng/views/image/image_page.dart';
1818
import 'package:piwigo_ng/views/image/image_search_page.dart';
19+
import 'package:piwigo_ng/views/image/image_tags_page.dart';
1920
import 'package:piwigo_ng/views/image/video_player_page.dart';
2021
import 'package:piwigo_ng/views/settings/auto_upload_page.dart';
2122
import 'package:piwigo_ng/views/settings/privacy_policy_page.dart';
@@ -147,6 +148,14 @@ Route<dynamic> generateRoute(RouteSettings settings) {
147148
),
148149
settings: settings,
149150
);
151+
case ImageTagsPage.routeName:
152+
return MaterialPageRoute(
153+
builder: (_) => ImageTagsPage(
154+
isAdmin: arguments['isAdmin'] ?? isAdmin,
155+
tag: arguments["tag"],
156+
),
157+
settings: settings,
158+
);
150159
case ImageFavoritesPage.routeName:
151160
return MaterialPageRoute(
152161
builder: (_) => ImageFavoritesPage(

lib/components/appbars/root_search_app_bar.dart

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:piwigo_ng/components/modals/open_tag_modal.dart';
23
import 'package:piwigo_ng/components/notification_dot.dart';
34
import 'package:piwigo_ng/components/popup_list_item.dart';
45
import 'package:piwigo_ng/services/preferences_service.dart';
@@ -40,9 +41,7 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
4041
if (widget.scrollController.offset > _expandedHeight * _opacityScale) {
4142
return 0.0;
4243
}
43-
return (_expandedHeight * _opacityScale -
44-
widget.scrollController.offset) /
45-
(_expandedHeight * _opacityScale);
44+
return (_expandedHeight * _opacityScale - widget.scrollController.offset) / (_expandedHeight * _opacityScale);
4645
}
4746
return 1.0;
4847
}
@@ -58,8 +57,7 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
5857
}
5958

6059
// In case 0%-100% of the expanded height is viewed
61-
double scrollDelta =
62-
(_expandedHeight - widget.scrollController.offset) / _expandedHeight;
60+
double scrollDelta = (_expandedHeight - widget.scrollController.offset) / _expandedHeight;
6361
double scrollPercent = (scrollDelta * 2 - 1);
6462
return (1 - scrollPercent) * delta * basePadding + basePadding;
6563
}
@@ -71,8 +69,7 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
7169
Widget build(BuildContext context) {
7270
return SliverAppBar(
7371
leading: IconButton(
74-
onPressed: () =>
75-
Navigator.of(context).pushNamed(SettingsPage.routeName),
72+
onPressed: () => Navigator.of(context).pushNamed(SettingsPage.routeName),
7673
icon: const Icon(Icons.settings),
7774
),
7875
pinned: true,
@@ -90,7 +87,6 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
9087
child: AppField(
9188
padding: const EdgeInsets.symmetric(vertical: 8.0),
9289
prefix: Icon(Icons.search),
93-
hint: "Search...",
9490
),
9591
),
9692
),
@@ -125,8 +121,7 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
125121
PopupMenuItem(
126122
onTap: () => Future.delayed(
127123
const Duration(seconds: 0),
128-
() =>
129-
Navigator.of(context).pushNamed(UploadStatusPage.routeName),
124+
() => Navigator.of(context).pushNamed(UploadStatusPage.routeName),
130125
),
131126
child: Stack(
132127
children: [
@@ -137,8 +132,7 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
137132
Positioned(
138133
top: 14.0,
139134
left: 0.0,
140-
child: Consumer<UploadNotifier>(
141-
builder: (context, uploadNotifier, child) {
135+
child: Consumer<UploadNotifier>(builder: (context, uploadNotifier, child) {
142136
return NotificationDot(
143137
isShown: uploadNotifier.uploadList.isNotEmpty,
144138
);
@@ -147,12 +141,21 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
147141
],
148142
),
149143
),
144+
PopupMenuItem(
145+
onTap: () => Future.delayed(
146+
const Duration(seconds: 0),
147+
() => showOpenTagModal(context),
148+
),
149+
child: PopupListItem(
150+
icon: Icons.local_offer_outlined,
151+
text: appStrings.tags,
152+
),
153+
),
150154
if (Preferences.getUserStatus != 'guest')
151155
PopupMenuItem(
152156
onTap: () => Future.delayed(
153157
const Duration(seconds: 0),
154-
() => Navigator.of(context)
155-
.pushNamed(ImageFavoritesPage.routeName),
158+
() => Navigator.of(context).pushNamed(ImageFavoritesPage.routeName),
156159
),
157160
child: PopupListItem(
158161
icon: Icons.favorite,
@@ -164,8 +167,7 @@ class _RootSearchAppBarState extends State<RootSearchAppBar> {
164167
Positioned(
165168
top: 12.0,
166169
left: 12.0,
167-
child: Consumer<UploadNotifier>(
168-
builder: (context, uploadNotifier, child) {
170+
child: Consumer<UploadNotifier>(builder: (context, uploadNotifier, child) {
169171
return NotificationDot(
170172
isShown: uploadNotifier.uploadList.isNotEmpty,
171173
);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
3+
import 'package:piwigo_ng/components/fields/app_field.dart';
4+
import 'package:piwigo_ng/models/tag_model.dart';
5+
import 'package:piwigo_ng/network/api_error.dart';
6+
import 'package:piwigo_ng/network/tags.dart';
7+
import 'package:piwigo_ng/utils/localizations.dart';
8+
import 'package:piwigo_ng/views/image/image_tags_page.dart';
9+
10+
class OpenTagModal extends StatefulWidget {
11+
const OpenTagModal({super.key});
12+
13+
@override
14+
_OpenTagModalState createState() => _OpenTagModalState();
15+
}
16+
17+
class _OpenTagModalState extends State<OpenTagModal> {
18+
final ScrollController _scrollController = ScrollController();
19+
late final Future _tagsFuture;
20+
21+
String _searchQuery = '';
22+
List<TagModel>? _tagList;
23+
24+
@override
25+
void initState() {
26+
super.initState();
27+
_tagsFuture = _onRefresh();
28+
}
29+
30+
@override
31+
void dispose() {
32+
_scrollController.dispose();
33+
super.dispose();
34+
}
35+
36+
Future<void> _onRefresh() async {
37+
try {
38+
final ApiResponse<List<TagModel>> result = await getTags();
39+
if (!result.hasData) return;
40+
setState(() {
41+
_tagList = result.data!.where((tag) => tag.counter > 0).toList()..sort((a, b) => a.name.compareTo(b.name));
42+
});
43+
} catch (e) {
44+
setState(() {
45+
_tagList = null;
46+
});
47+
}
48+
}
49+
50+
void _onSelectTag(TagModel tag) {
51+
Navigator.of(context).pop();
52+
Navigator.of(context).pushNamed(
53+
ImageTagsPage.routeName,
54+
arguments: {
55+
'tag': tag,
56+
},
57+
);
58+
}
59+
60+
@override
61+
Widget build(BuildContext context) {
62+
return Scaffold(
63+
backgroundColor: Colors.transparent,
64+
appBar: AppBar(
65+
shape: const RoundedRectangleBorder(
66+
borderRadius: BorderRadius.vertical(
67+
top: Radius.circular(15.0),
68+
),
69+
),
70+
elevation: 0.0,
71+
scrolledUnderElevation: 5.0,
72+
leading: IconButton(
73+
icon: Icon(Icons.close),
74+
onPressed: () => Navigator.of(context).pop(),
75+
),
76+
title: Text(appStrings.tags),
77+
),
78+
body: ListView(
79+
controller: ModalScrollController.of(context),
80+
physics: const AlwaysScrollableScrollPhysics(),
81+
children: [
82+
Padding(
83+
padding: const EdgeInsets.symmetric(
84+
horizontal: 16.0,
85+
vertical: 8.0,
86+
),
87+
child: AppField(
88+
padding: const EdgeInsets.symmetric(vertical: 8.0),
89+
prefix: Icon(Icons.search),
90+
onChanged: (query) => setState(() {
91+
_searchQuery = query;
92+
}),
93+
),
94+
),
95+
FutureBuilder(
96+
future: _tagsFuture,
97+
builder: (context, snapshot) {
98+
switch (snapshot.connectionState) {
99+
case ConnectionState.done:
100+
return _buildTagList();
101+
default:
102+
return Center(
103+
child: CircularProgressIndicator(),
104+
);
105+
}
106+
},
107+
),
108+
],
109+
),
110+
);
111+
}
112+
113+
Widget _buildTagList() {
114+
if (_tagList == null) {
115+
return Center(
116+
child: Text(appStrings.coreDataFetch_TagError),
117+
);
118+
}
119+
120+
List<TagModel> tags =
121+
_tagList!.where((tag) => tag.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
122+
if (tags.isEmpty) {
123+
return Center(
124+
child: Text(appStrings.none),
125+
);
126+
}
127+
128+
return Column(
129+
children: tags.map((tag) => _buildItem(tag)).toList(),
130+
);
131+
}
132+
133+
Widget _buildItem(TagModel tag) => ListTile(
134+
visualDensity: VisualDensity.compact,
135+
shape: Border(
136+
bottom: BorderSide(color: Theme.of(context).scaffoldBackgroundColor),
137+
),
138+
title: Text(tag.name),
139+
trailing: Text(appStrings.imageCount(tag.counter)),
140+
onTap: () => _onSelectTag(tag),
141+
);
142+
}
143+
144+
Future<int?> showOpenTagModal(BuildContext context) async {
145+
return showMaterialModalBottomSheet<int>(
146+
context: context,
147+
enableDrag: false,
148+
shape: RoundedRectangleBorder(
149+
borderRadius: BorderRadius.vertical(top: Radius.circular(30.0)),
150+
),
151+
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
152+
builder: (context) => OpenTagModal(),
153+
);
154+
}

lib/components/modals/select_tags_modal.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,10 @@ class _SelectTagsModalState extends State<SelectTagsModal> {
4141
super.dispose();
4242
}
4343

44-
List<TagModel> get _unselectedTagList =>
45-
_tagList!.where((t) => !_selectedTagList.contains(t)).toList();
44+
List<TagModel> get _unselectedTagList => _tagList!.where((t) => !_selectedTagList.contains(t)).toList();
4645

4746
Future<void> _onRefresh() async {
48-
final ApiResponse<List<TagModel>> result = await getTags();
47+
final ApiResponse<List<TagModel>> result = await getAdminTags();
4948
if (!result.hasData) return;
5049
setState(() {
5150
_tagList = result.data!;

lib/network/images.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,49 @@ Future<ApiResponse<Map>> fetchFavorites([
167167
return ApiResponse(error: ApiErrors.error);
168168
}
169169

170+
Future<ApiResponse<Map>> fetchTagImages(int tagID, [int page = 0]) async {
171+
Map<String, String> query = {
172+
"format": "json",
173+
"method": "pwg.tags.getImages",
174+
"tag_id": tagID.toString(),
175+
"per_page": "100",
176+
"page": page.toString(),
177+
};
178+
179+
try {
180+
Response response = await ApiClient.get(queryParameters: query);
181+
182+
if (response.statusCode == 200) {
183+
final Map<String, dynamic> result = json.decode(response.data);
184+
if (result['stat'] == 'fail') {
185+
return ApiResponse<Map>(data: {
186+
'total_count': 0,
187+
'images': [],
188+
});
189+
}
190+
final jsonImages = result['result']['images'];
191+
List<ImageModel> images = List<ImageModel>.from(
192+
jsonImages.map((image) {
193+
image['tags'] = null;
194+
return ImageModel.fromJson(image);
195+
}),
196+
);
197+
198+
print(result['result']['paging']);
199+
200+
return ApiResponse<Map>(data: {
201+
'total_count': result['result']['paging']['total_count'],
202+
'images': images,
203+
});
204+
}
205+
} on DioError catch (e) {
206+
debugPrint('Fetch tag images: ${e.message}');
207+
} on Error catch (e) {
208+
debugPrint('Fetch tag images: ${e.stackTrace}');
209+
}
210+
return ApiResponse(error: ApiErrors.error);
211+
}
212+
170213
Future<String?> pickDirectoryPath() async {
171214
return await FilePicker.platform.getDirectoryPath();
172215
}

0 commit comments

Comments
 (0)