diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a998e9..2a4eed2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ > queryBlogByConditionApi( ); } +Future> searchBlog(String keyword) { + return HttpUtil().get>( + "/blog/search", + queryParameters: {"keyword": keyword}, + converter: + (data) => convertListResponse(data, BlogSearch.fromJson), + ); +} + Future queryBlogByIdApi(int id) { return HttpUtil().get( "/blog/$id/content", diff --git a/lib/models/blog.dart b/lib/models/blog.dart index e9dfbe7..1edfc74 100644 --- a/lib/models/blog.dart +++ b/lib/models/blog.dart @@ -107,3 +107,30 @@ class BlogStats { Map toJson() => _$BlogStatsToJson(this); } + +@JsonSerializable(genericArgumentFactories: true) +class BlogSearch { + @JsonKey(name: 'id') + final int id; + + @JsonKey(name: 'title') + final String title; + + @JsonKey(name: 'content') + final String content; + + @JsonKey(name: 'highlight') + final String highlight; + + const BlogSearch({ + required this.id, + required this.title, + required this.content, + required this.highlight, + }); + + factory BlogSearch.fromJson(Map json) => + _$BlogSearchFromJson(json); + + Map toJson() => _$BlogSearchToJson(this); +} diff --git a/lib/models/blog.g.dart b/lib/models/blog.g.dart index 671ee98..8a0093d 100644 --- a/lib/models/blog.g.dart +++ b/lib/models/blog.g.dart @@ -59,3 +59,18 @@ Map _$BlogStatsToJson(BlogStats instance) => { 'visitCount': instance.visitCount, 'wordCount': instance.wordCount, }; + +BlogSearch _$BlogSearchFromJson(Map json) => BlogSearch( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + highlight: json['highlight'] as String, +); + +Map _$BlogSearchToJson(BlogSearch instance) => + { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'highlight': instance.highlight, + }; diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index bbf626f..d1a68eb 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,5 +1,8 @@ +import 'package:blog_app/apis/blog.dart'; import 'package:blog_app/layout/drawer.dart'; import 'package:blog_app/layout/menu.dart'; +import 'package:blog_app/models/blog.dart'; +import 'package:blog_app/widget/search.dart'; import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { @@ -11,6 +14,12 @@ class _HomePageState extends State { int _currentPageIndex = 0; late PageController _pageController; + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + List _searchResults = []; + String _searchQuery = ''; + @override void initState() { super.initState(); @@ -20,53 +29,145 @@ class _HomePageState extends State { @override void dispose() { _pageController.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } + void _startSearch() { + setState(() { + _isSearching = true; + }); + Future.delayed(Duration(milliseconds: 100), () { + _searchFocusNode.requestFocus(); + }); + } + + void _exitSearch() { + setState(() { + _isSearching = false; + _searchController.clear(); + _searchResults.clear(); + _searchQuery = ''; + }); + FocusScope.of(context).unfocus(); + } + + void _clearSearch() { + _searchController.clear(); + setState(() { + _searchResults.clear(); + _searchQuery = ''; + }); + } + + void _onSearchTextChanged(String query) async { + setState(() { + _searchQuery = query; + }); + + if (query.isEmpty) { + setState(() { + _searchResults.clear(); + }); + return; + } + + final result = await searchBlog(query); + + setState(() { + _searchResults = result; + }); + } + + Widget _buildLeading() { + if (_isSearching) { + return IconButton( + icon: Icon(Icons.arrow_back, color: Colors.white), + onPressed: _exitSearch, + ); + } + + return Builder( + builder: + (context) => IconButton( + icon: Icon(Icons.menu, color: Colors.white), + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ); + } + + Widget _buildAppBarTitle() { + if (_isSearching) { + return buildSearchField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: _onSearchTextChanged, + ); + } + + return Text( + pages[_currentPageIndex].title, + style: TextStyle(color: Colors.white), + ); + } + + List _buildAppBarActions() { + if (_isSearching) { + return [ + if (_searchController.text.isNotEmpty) + IconButton( + icon: Icon(Icons.clear, color: Colors.white), + onPressed: _clearSearch, + ), + ]; + } else { + return [ + IconButton( + icon: Icon(Icons.search, color: Colors.white), + onPressed: _startSearch, + ), + ]; + } + } + Widget _buildPageView() { + return _isSearching ? _buildSearchResults() : _buildNormalPageView(); + } + + Widget _buildNormalPageView() { return PageView( controller: _pageController, onPageChanged: (index) { setState(() { _currentPageIndex = index; + if (_isSearching) { + _exitSearch(); + } }); }, children: pageWidgets, ); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - pages[_currentPageIndex].title, - style: TextStyle(color: Colors.white), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - elevation: 0, - leading: Builder( - builder: - (context) => IconButton( - icon: Icon(Icons.menu, color: Colors.white), - onPressed: () => Scaffold.of(context).openDrawer(), - ), - ), - actions: [ - IconButton( - icon: Icon(Icons.search, color: Colors.white), - onPressed: () {}, - ), - ], - ), - drawer: _buildDrawer(), - body: Padding(padding: EdgeInsets.all(10), child: _buildPageView()), - ); + Widget _buildSearchResults() { + if (_searchQuery.isEmpty) { + return buildSearchQueryEmpty(); + } + + if (_searchResults.isEmpty) { + return buildSearchResultEmpty(_searchQuery); + } + + return buildSearchResultList(context, _searchResults); } void onTapDrawerItem(int index) { setState(() { _currentPageIndex = index; + if (_isSearching) { + _exitSearch(); + } }); _pageController.animateToPage( index, @@ -99,13 +200,26 @@ class _HomePageState extends State { color: Colors.white, child: Column( children: [ - // 抽屉头部 buildDrawerHeader(context), - // 菜单项列表 Expanded(child: _buildDrawerBody()), ], ), ), ); } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: _buildAppBarTitle(), + backgroundColor: Theme.of(context).colorScheme.primary, + elevation: 0, + leading: _buildLeading(), + actions: _buildAppBarActions(), + ), + drawer: _buildDrawer(), + body: Padding(padding: EdgeInsets.all(10), child: _buildPageView()), + ); + } } diff --git a/lib/pages/timeline_page.dart b/lib/pages/timeline_page.dart index 1f087dc..55b62a0 100644 --- a/lib/pages/timeline_page.dart +++ b/lib/pages/timeline_page.dart @@ -38,10 +38,10 @@ class TimelinePageState extends State { return Indicator.dot(size: 12.0, color: colors.primary); }, contentsBuilder: (context, index) { - return buildBlogListItem( - context, - provider.blogList[index], - index + 1, + final blog = provider.blogList[index]; + return InkWell( + onTap: () => navigatorToBlogDetail(context, blog.id), + child: buildBlogListItem(context, blog, index + 1), ); }, ), diff --git a/lib/widget/search.dart b/lib/widget/search.dart new file mode 100644 index 0000000..7a2a642 --- /dev/null +++ b/lib/widget/search.dart @@ -0,0 +1,99 @@ +import 'package:blog_app/models/blog.dart'; +import 'package:blog_app/pages/blog_detail_page.dart'; +import 'package:blog_app/widget/blog.dart'; +import 'package:blog_app/widget/common.dart'; +import 'package:flutter/material.dart'; + +Widget buildSearchField({ + required TextEditingController controller, + required FocusNode focusNode, + required Function(String) onChanged, +}) { + return TextField( + controller: controller, + focusNode: focusNode, + autofocus: true, + style: TextStyle(color: Colors.white, fontSize: 18), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: '搜索文章内容...', + hintStyle: TextStyle(color: Colors.white70), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + ), + onChanged: onChanged, + textInputAction: TextInputAction.search, + ); +} + +Widget buildSearchResultItem(BuildContext context, BlogSearch blog) { + return buildCard( + context: context, + child: ListTile( + title: Text( + blog.title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 4), + Text( + blog.content, + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), + ], + ), + onTap: () => navigatorToBlogDetail(context, blog.id), + ), + ); +} + +Widget buildSearchResultList( + BuildContext context, + List searchResultList, +) { + return ListView.builder( + itemCount: searchResultList.length, + itemBuilder: (context, index) { + return buildSearchResultItem(context, searchResultList[index]); + }, + ); +} + +Widget buildSearchQueryEmpty() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search, size: 64, color: Colors.grey[400]), + SizedBox(height: 16), + Text('搜索文章内容', style: TextStyle(color: Colors.grey[600], fontSize: 16)), + SizedBox(height: 8), + Text('输入标题或内容进行搜索', style: TextStyle(color: Colors.grey[500])), + ], + ), + ); +} + +Widget buildSearchResultEmpty(String query) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 64, color: Colors.grey[400]), + SizedBox(height: 16), + Text( + '没有找到"$query"相关文章', + style: TextStyle(color: Colors.grey[600], fontSize: 16), + ), + SizedBox(height: 8), + Text('请尝试其他关键词', style: TextStyle(color: Colors.grey[500])), + ], + ), + ); +}