diff --git a/violet/lib/database/user/bookmark.dart b/violet/lib/database/user/bookmark.dart index b3cc2fb37..8177da99c 100644 --- a/violet/lib/database/user/bookmark.dart +++ b/violet/lib/database/user/bookmark.dart @@ -161,17 +161,36 @@ class BookmarkCropImage { int page() => result['Page']; double aspectRatio() => result['AspectRatio']; String area() => result['Area']; + String userId() => result['UserId']; Future update() async { var db = await CommonUserDatabase.getInstance(); await db.update('BookmarkCropImage', result, 'Id=?', [id()]); } + factory BookmarkCropImage.fromJson(Map json) { + return BookmarkCropImage(result: { + 'Article': json['article'], + 'Page': json['page'], + 'Area': json['area'], + 'AspectRatio': json['aspectRatio'], + 'DateTime': json['datetime'], + if (json.containsKey('user_id')) 'UserId': json['user_id'], + }); + } + + static List fromJsonList(List jsonData) { + return jsonData + .map((e) => BookmarkCropImage.fromJson(e as Map)) + .toList(); + } + Map toJson() => { 'article': article(), 'page': page(), 'aspectRatio': aspectRatio(), 'area': area(), + // datetime and user_id are not included in the json }; } diff --git a/violet/lib/pages/bookmark/crop_bookmark.dart b/violet/lib/pages/bookmark/crop_bookmark.dart index a5c53b38e..b028a2c0f 100644 --- a/violet/lib/pages/bookmark/crop_bookmark.dart +++ b/violet/lib/pages/bookmark/crop_bookmark.dart @@ -17,9 +17,11 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; +import 'package:violet/database/query.dart'; import 'package:violet/database/user/bookmark.dart'; import 'package:violet/log/log.dart'; import 'package:violet/other/dialogs.dart'; +import 'package:violet/pages/bookmark/crop_id_rank_page.dart'; import 'package:violet/pages/common/toast.dart'; import 'package:violet/pages/common/utils.dart'; import 'package:violet/pages/segment/platform_navigator.dart'; @@ -326,17 +328,8 @@ class _CropBookmarkPageState extends State { file.writeContent(outputStream); final json = jsonDecode(utf8.decode(outputStream.getBytes())); - List bookmarks = []; - for (final e in json as List) { - final bookmark = BookmarkCropImage(result: { - 'Article': e['article'], - 'Page': e['page'], - 'Area': e['area'], - 'AspectRatio': e['aspectRatio'], - 'DateTime': e['datetime'], - }); - bookmarks.add(bookmark); - } + final bookmarks = + BookmarkCropImage.fromJsonList(json as List); if (!context.mounted) return; PlatformNavigator.navigateSlide( @@ -351,6 +344,70 @@ class _CropBookmarkPageState extends State { } }, ), + PullDownMenuItem( + title: 'User Bookmarks (with Id)', + icon: CupertinoIcons.bookmark, + onTap: () async { + final jsons = json.decode(await rootBundle.loadString( + 'assets/crop-bookmarks-user.json')) as List; + final bookmarks = BookmarkCropImage.fromJsonList(jsons); + + if (!context.mounted) return; + PlatformNavigator.navigateSlide( + context, + CropBookmarkPage( + bookmarks: bookmarks + .sortedBy((e) => DateTime.parse(e.datetime())) + .toList()), + opaque: false, + ); + }, + ), + PullDownMenuItem( + title: 'User Bookmarks (with Id Rank)', + icon: CupertinoIcons.bookmark, + onTap: () async { + if (!context.mounted) return; + PlatformNavigator.navigateSlide( + context, + CropIdRankPage(), + opaque: false, + ); + }, + ), + PullDownMenuItem( + title: 'User Bookmarks (with Custom Tag)', + icon: CupertinoIcons.bookmark, + onTap: () async { + final jsons = json.decode(await rootBundle.loadString( + 'assets/crop-bookmarks-user.json')) as List; + final bookmarks = BookmarkCropImage.fromJsonList(jsons); + + final queryRaw = await QueryManager.queryIds( + bookmarks.map((e) => e.article()).toList()); + final remainIds = queryRaw + .where((e) => e.tags() != null) + .where((e) => (e.tags() as String) + .split('|') + .contains('female:sole female')) + .map((e) => e.id()) + .toSet(); + + final remainBookmarks = bookmarks + .where((e) => remainIds.contains(e.article())) + .toList(); + + if (!context.mounted) return; + PlatformNavigator.navigateSlide( + context, + CropBookmarkPage( + bookmarks: remainBookmarks + .sortedBy((e) => DateTime.parse(e.datetime())) + .toList()), + opaque: false, + ); + }, + ), // TODO: enable select mode // const PullDownMenuDivider.large(), // PullDownMenuItem( diff --git a/violet/lib/pages/bookmark/crop_id_rank_page.dart b/violet/lib/pages/bookmark/crop_id_rank_page.dart new file mode 100644 index 000000000..9bead75e1 --- /dev/null +++ b/violet/lib/pages/bookmark/crop_id_rank_page.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:violet/database/user/bookmark.dart'; +import 'package:violet/pages/bookmark/crop_bookmark.dart' + show CropBookmarkPage, CropImageWidget; +import 'package:violet/pages/common/utils.dart' show getImageProviderFromId; + +class CropIdRankPage extends StatefulWidget { + const CropIdRankPage({super.key}); + + @override + State createState() => _CropIdRankPageState(); +} + +class _UserCropRank { + final String userId; + final List> crops; + const _UserCropRank({ + required this.userId, + required this.crops, + }); +} + +class _CropIdRankPageState extends State { + late Future> _futureRank; + + @override + void initState() { + super.initState(); + _futureRank = _loadRank(); + } + + Future> _loadRank() async { + final jsonStr = + await rootBundle.loadString('assets/crop-bookmarks-user.json'); + final List data = json.decode(jsonStr); + final Map>> userMap = {}; + for (final e in data) { + final userId = e['user_id'] as String?; + if (userId == null) continue; + userMap.putIfAbsent(userId, () => []).add(e as Map); + } + final list = userMap.entries + .map((e) => _UserCropRank( + userId: e.key, + crops: e.value, + )) + .toList(); + list.sort((a, b) => b.crops.length.compareTo(a.crops.length)); + return list; + } + + void _onUserTap(_UserCropRank user) { + final bookmarks = BookmarkCropImage.fromJsonList(user.crops); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CropBookmarkPage(bookmarks: bookmarks), + ), + ); + } + + Widget _buildCropPreviews(List> crops) { + return Row( + children: crops.take(6).map((crop) { + final articleId = crop['article'] as int; + final page = crop['page'] as int; + final area = (crop['area'] as String) + .split(',') + .map((e) => double.parse(e)) + .toList(); + final aspectRatio = (crop['aspectRatio'] as num).toDouble(); + final rect = Rect.fromLTRB(area[0], area[1], area[2], area[3]); + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: FutureBuilder<({String url, Map headers})>( + future: () async { + final provider = await getImageProviderFromId(articleId); + final url = await provider.getImageUrl(page); + final headers = await provider.getHeader(page); + return (url: url, headers: headers); + }(), + builder: (context, snap) { + if (!snap.hasData) { + return const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2))); + } + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CropImageWidget( + articleId: articleId, + page: page, + url: snap.data!.url, + headers: snap.data!.headers, + rect: rect, + aspectRatio: aspectRatio, + columnCount: 1, + showOverlay: ValueNotifier(false), + ), + ); + }, + ), + ), + ); + }).toList(), + ); + } + + Widget _buildUserRankTile(_UserCropRank user, int idx) { + return InkWell( + onTap: () => _onUserTap(user), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('#${idx + 1}', + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + Expanded(child: Text(user.userId)), + Text('${user.crops.length}', + style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 8), + _buildCropPreviews(user.crops), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Crop Bookmark User Rank')), + body: FutureBuilder>( + future: _futureRank, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final rankList = snapshot.data!; + return ListView.separated( + itemCount: rankList.length, + separatorBuilder: (context, idx) => const Divider(height: 1), + itemBuilder: (context, idx) => + _buildUserRankTile(rankList[idx], idx), + ); + }, + ), + ); + } +} diff --git a/violet/pubspec.yaml b/violet/pubspec.yaml index 6627d2ec6..631f4b72f 100644 --- a/violet/pubspec.yaml +++ b/violet/pubspec.yaml @@ -220,6 +220,8 @@ flutter: - assets/rank/similar_articles_with_scores.json + - assets/crop-bookmarks-user.json + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/violet/script/daily-crop.py b/violet/script/daily-crop.py index 03db1502e..8c7ace5de 100644 --- a/violet/script/daily-crop.py +++ b/violet/script/daily-crop.py @@ -19,6 +19,9 @@ query = """ SELECT event_timestamp, + (SELECT param.value.string_value + FROM UNNEST(user_properties) AS param + WHERE param.key = 'user_id') as user_id, (SELECT param.value.string_value FROM UNNEST(event_params) AS param WHERE param.key = 'Area') as area, @@ -35,8 +38,6 @@ `real-violet-app.analytics_238885015.events_*` WHERE event_name = "bookmark_crop" -LIMIT - 5000 """ datas = [] @@ -47,6 +48,7 @@ "aspectRatio": row["aspect_ratio"], "article": row["article"], "page": row["page"], + "user_id": row["user_id"], } dedup = json.dumps(data) if dedup not in dedups: @@ -60,7 +62,7 @@ print(len(datas)) -filename = "assets/daily/crop-bookmarks.json" -os.makedirs(os.path.dirname(filename), exist_ok=True) +filename = "crop-bookmarks-5.json" +# os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, "w") as file: file.write(json.dumps(datas))