目标:把前 10 天内容整合,做一个 “简易新闻 App”
功能清单:
登录页(本地存储记住账号)
新闻列表页(Dio 请求数据、加载状态、下拉刷新)
新闻详情页(接收列表页传值、显示内容)
实践任务:
完成 App 核心功能,确保 Android/iOS/Web/ 桌面端能正常运行
修复适配问题(如 Web 端列表滚动、桌面端窗口大小)
注:本章只覆盖安卓和桌面端。
一、接口准备※
app新闻数据使用今日头条热搜数据,api使用SHWGIJ API(https://api.shwgij.com/),接口需要token认证,每日一千次免费调用,token获取步骤:
注册并登录账号
登录成功后会自动进入用户控制台,左侧切换到秘钥管理

- 点击申请秘钥,底下密钥安全设置默认关闭(选择其他方式请求的时候需要加验证,会比较麻烦,这个项目只是为了复习学习成果,无需考虑安全问题)。
左侧点击
产品列表,切换到新闻标签,点击头条热搜新闻榜,即可查看相应的文档,且可以在线调试。

二、项目初始化与环境配置※
新建项目
在终端执行如下命令:
flutter create news_app # 创建一个名为news_app的项目 cd news_app # 进入项目根目录注意:如无特别说明,后文中执行命令均在news_app这个根目录下执行。
添加依赖
打开根目录下的
pubspec.yaml文件,在dependencies部分添加如下内容并保存:dependencies: flutter: sdk: flutter # 网络请求 dio: ^5.4.0 # JSON 序列化 json_annotation: ^4.9.0 # 本地存储 shared_preferences: ^2.5.3 # 从环境中加载秘钥等配置 flutter_dotenv: ^6.0.0 # URL渲染 webview_flutter: ^4.13.0 webview_windows: ^0.4.0 dev_dependencies: flutter_test: sdk: flutter # JSON 序列化代码生成器 build_runner: ^2.10.4 json_serializable: ^6.11.3 # 图标生成 flutter_launcher_icons: ^0.14.4 flutter: assets: - .env # 让Flutter打包时能包含.env文件 # 图标生成配置(与 dev_dependencies 同级,建议添加到配置文件末尾) flutter_icons: android: true # 生成 Android 图标 ios: true # 生成 iOS 图标 image_path: "assets/icons/icon.png" # 你的图标素材路径 adaptive_icon_background: "#FFFFFF" # Android 自适应图标背景色(可改为你的品牌色) adaptive_icon_foreground: "assets/icons/icon.png" # Android 自适应图标前景(同素材即可) remove_alpha_ios: true # iOS 图标移除透明通道(避免审核问题)获取依赖
在终端执行如下命令:
flutter pub get- 安卓支持:JAVA需要在21以上。
三、项目代码※
1. 项目结构※
/news_app
┣assets
┃ ┗icons
┃ ┗icon.png
┣lib
┃ ┣models
┃ ┃ ┣account_model.dart
┃ ┃ ┣news_model.dart
┃ ┃ ┗news_model.g.dart(使用json序列化工具自动生成的文件,无需手动创建)
┃ ┣pages
┃ ┃ ┣login_page.dart
┃ ┃ ┣news_detail_page.dart
┃ ┃ ┣news_list_page.dart
┃ ┃ ┣register_page.dart
┃ ┃ ┗reset_password_page.dart
┃ ┣services
┃ ┃ ┣news_api_service.dart
┃ ┃ ┗storage_service.dart
┃ ┣widgets
┃ ┃ ┗platform_webview.dart
┃ ┗main.dart
┣.env
┗...其他目录或文件2. 各部分代码及说明※
(1)assets目录※
该目录用于放置app图标,默认没有该目录,按照层级关系创建并放入图片即可,注意,pubspec.yaml中定义的flutter_icons中的路径需要与该层级保持一致。
图片放置及配置完毕后,在终端执行如下命令:
flutter pub run flutter_launcher_icons若有类似如下输出:
✓ Successfully generated launcher icons for Android
✓ Successfully generated launcher icons for iOS则图标生成成功,可以在:
Android: android/app/src/main/res/ (各种 mipmap-xxx 文件夹)
iOS:ios/Runner/Assets.xcassets/AppIcon.appiconset/
目录下看到生成的图标,执行flutter run 或者 flutter build apk/ipa可以使图标生效。
(2)models目录※
该目录存放各种数据模型,各模型说明如下:
(a)账号模型:account_model.dart※
该部分存放账号相关信息,源码如下:
// 账号模型
class AccountModel {
final bool isLogin; // 登录状态
final String username; // 账号
final String password; // 密码
final DateTime? lastLoginTime; // 最后一次登录时间
AccountModel({
required this.username,
required this.password,
required this.isLogin,
this.lastLoginTime
});
// JSON反序列化
factory AccountModel.fromJson(Map<String, dynamic> json) {
return AccountModel(
username: json['username'],
password: json['password'],
isLogin: json['isLogin'] ?? false,
lastLoginTime: json['lastLoginTime'] != null ? DateTime.parse(json['lastLoginTime'] as String) : null);
}
// JSON序列化
Map<String, dynamic> toJson() {
return {
'username': username,
'password': password,
'isLogin': isLogin,
'lastLoginTime': lastLoginTime?.toIso8601String()
};
}
}(b)新闻模型:news_model.dart※
该部分存放新闻相关字段信息
源码
import 'package:json_annotation/json_annotation.dart'; part 'news_model.g.dart'; // 生成的代码文件 @JsonSerializable() class News { // 这里字段名要和接口返回的数据中的key一致 // 把ranking数字字符串混合统一转成字符串 @JsonKey(fromJson: _dynamicToString) final String ranking; final String Url; final String Title; final String LabelUrl; News({ required this.ranking, required this.Url, required this.Title, required this.LabelUrl }); static String _dynamicToString(dynamic value) { if (value == null) { return ''; } return value.toString(); } // 转换排名显示 String get displayRanking { return ranking == 'topContent' ? '置顶' : ranking; } // 从JSON映射到对象 factory News.fromJson(Map<String, dynamic> json) => _$NewsFromJson(json); // 从对象映射到JSON Map<String, dynamic> toJson() => _$NewsToJson(this); } // 顶层响应模型 @JsonSerializable() class NewsResponse { final int code; final String msg; final List<News> data; NewsResponse({ required this.code, required this.msg, required this.data }); factory NewsResponse.fromJson(Map<String, dynamic> json) => _$NewsResponseFromJson(json); Map<String, dynamic> toJson() => _$NewsResponseToJson(this); }由于字段比较多,因此新闻模型选择用JSON序列化工具(json_annotation)来进行自动完成序列化与反序列化代码。创建完上述文件后,执行:
flutter pub run build_runner build这会在models目录下自动创建news_model.g.dart(news_model.dart中指定的文件名)。
(3)pages目录※
该目录下存放各个页面,各页面说明如下:
(a)登录页:login_page.dart※
import 'package:flutter/material.dart';
import 'package:news_app/services/storage_service.dart';
import 'package:news_app/models/account_model.dart';
import 'package:news_app/pages/register_page.dart';
import 'package:news_app/pages/reset_password_page.dart';
import 'package:news_app/pages/news_list_page.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<StatefulWidget> createState() => _LoginPage();
}
class _LoginPage extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _newAccountUsernameController = TextEditingController();
final _newAccountPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoading = false;
bool _showAddAccountForm = false; // 是否显示「添加账号」表单
List<AccountModel> _loggedInAccounts = []; // 登录状态为 true 的账号列表
AccountModel? _selectedAccount; // 当前选中的已登录账号
@override
void initState() {
super.initState();
_loadLoggedInAccounts(); // 初始化加载已登录账号
}
// 加载所有登录状态为 true 的账号
Future<void> _loadLoggedInAccounts() async {
final accounts = await StorageService.getAllLoggedInAccounts();
setState(() {
_loggedInAccounts = accounts;
// 默认选中第一个已登录账号
_selectedAccount = accounts.isNotEmpty ? accounts.first : null;
});
}
// 验证账号密码并登录(普通登录/添加账号登录)
Future<void> _handleLogin({bool isAddAccount = false}) async {
// 场景1:快速登录(有已登录账号,无 Form)
if (!isAddAccount && _loggedInAccounts.isNotEmpty && !_showAddAccountForm) {
if (_selectedAccount == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择登录账号!')),
);
}
return;
}
// 直接登录选中的已登录账号,不需要表单校验
setState(() => _isLoading = true);
try {
if (_selectedAccount != null) {
await StorageService.saveAccount(AccountModel(username: _selectedAccount!.username, password: _selectedAccount!.password, isLogin: true, lastLoginTime: DateTime.now()));
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登录成功!欢迎 ${_selectedAccount?.username}')),
);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const NewsListPage()),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登录失败:${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
return;
}
// 场景2:表单登录/添加账号(有 Form,需要校验)
final formState = _formKey.currentState;
if (formState == null) {
// 理论上不会走到这里,防止异常
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入账号密码!')),
);
}
return;
}
// 执行表单校验
if (formState.validate()) {
final username = isAddAccount
? _newAccountUsernameController.text.trim()
: _usernameController.text.trim();
final password = isAddAccount
? _newAccountPasswordController.text.trim()
: _passwordController.text.trim();
setState(() => _isLoading = true);
try {
// 验证账号密码
final verifiedAccount = await StorageService.verifyAccount(username, password);
if (verifiedAccount == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('账号不存在或密码错误,请重试!')),
);
}
return;
}
// 登录成功:更新该账号为登录状态
await StorageService.saveAccount(AccountModel(
username: verifiedAccount.username,
password: verifiedAccount.password,
isLogin: true,
lastLoginTime: DateTime.now()
));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登录成功!欢迎 ${verifiedAccount.username}')),
);
if (isAddAccount) {
setState(() {
_showAddAccountForm = false;
_newAccountUsernameController.clear();
_newAccountPasswordController.clear();
});
}
await _loadLoggedInAccounts();
if (mounted) {
// 跳转新闻页
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const NewsListPage()),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登录失败:${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
// 弹出账号管理窗口
void _showAccountManager() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('已登录账号'),
content: SizedBox(
width: 300,
child: ListView.builder(
shrinkWrap: true,
itemCount: _loggedInAccounts.length,
itemBuilder: (listContext, index) {
final account = _loggedInAccounts[index];
return ListTile(
title: Text(account.username),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: '移除该账号',
onPressed: () async {
// 移除逻辑:将该账号的登录状态设为 false
final updatedAccount = AccountModel(
username: account.username,
password: account.password,
isLogin: false,
);
await StorageService.saveAccount(updatedAccount);
// 刷新列表并关闭对话框
await _loadLoggedInAccounts();
if (listContext.mounted) {
Navigator.pop(context);
}
},
),
onTap: () {
// 选中账号:更新欢迎语并关闭对话框
setState(() => _selectedAccount = account);
Navigator.pop(context);
},
);
},
),
),
),
);
}
// 跳转到注册页
void _gotoRegister() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => RegisterPage()),
).then((_) {
// 注册成功后返回,刷新已登录账号列表
_loadLoggedInAccounts();
});
}
// 跳转到重置密码页
void _gotoResetPassword() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ResetPasswordPage()),
).then((_) {
_loadLoggedInAccounts();
});
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 600;
final screenWidth = MediaQuery.of(context).size.width;
return Scaffold(
body: Center(
child: Container(
width: isDesktop ? screenWidth * 0.3 : screenWidth * 0.9,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: _buildBody(),
),
),
);
}
// 构建页面主体(根据已登录账号列表是否为空切换 UI)
Widget _buildBody() {
// 已登录账号列表不为空:显示欢迎语 + 账号管理
if (_loggedInAccounts.isNotEmpty && !_showAddAccountForm) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 欢迎语
Text(
'欢迎 ${_selectedAccount?.username}~',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// 账号管理按钮
ElevatedButton(
onPressed: _showAccountManager,
child: const Text('账号管理'),
),
const SizedBox(height: 40),
// 快速登录按钮
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _handleLogin(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('快速登录', style: TextStyle(fontSize: 18)),
),
),
const SizedBox(height: 20),
// 功能按钮组
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () => setState(() => _showAddAccountForm = true),
child: const Text('添加账号'),
),
TextButton(
onPressed: _gotoRegister,
child: const Text('注册'),
),
TextButton(
onPressed: _gotoResetPassword,
child: const Text('重置密码'),
),
],
),
],
);
}
// 已登录账号为空 或 显示添加账号表单:显示登录/添加账号表单
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题
Text(
_showAddAccountForm ? '添加已有账号' : '登录',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 30),
// 账号输入框
TextFormField(
controller: _showAddAccountForm ? _newAccountUsernameController : _usernameController,
decoration: InputDecoration(
labelText: '账号',
hintText: '请输入账号',
prefixIcon: const Icon(Icons.account_circle),
border: const OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入账号';
}
return null;
},
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
// 密码输入框
TextFormField(
controller: _showAddAccountForm ? _newAccountPasswordController : _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: const OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入密码';
}
return null;
},
keyboardType: TextInputType.visiblePassword,
),
const SizedBox(height: 24),
// 登录/添加账号按钮
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _handleLogin(isAddAccount: _showAddAccountForm),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
_showAddAccountForm ? '添加并登录' : '登录',
style: const TextStyle(fontSize: 18),
),
),
),
const SizedBox(height: 16),
// 返回按钮(添加账号表单时显示)
if (_showAddAccountForm)
TextButton(
onPressed: () {
setState(() {
_showAddAccountForm = false;
_newAccountUsernameController.clear();
_newAccountPasswordController.clear();
});
},
child: const Text('返回'),
),
// 功能按钮组
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () => setState(() => _showAddAccountForm = true),
child: const Text('添加账号'),
),
TextButton(
onPressed: _gotoRegister,
child: const Text('注册'),
),
TextButton(
onPressed: _gotoResetPassword,
child: const Text('重置密码'),
),
],
),
],
),
);
}
}(b)新闻详情页:news_detail_page.dart※
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:news_app/models/news_model.dart';
import 'package:webview_flutter/webview_flutter.dart' as webview_flutter;
import 'package:webview_windows/webview_windows.dart' as webview_windows;
class PlatformWebView extends StatefulWidget {
final String url;
const PlatformWebView({super.key, required this.url});
@override
State<PlatformWebView> createState() => _PlatformWebViewState();
}
class _PlatformWebViewState extends State<PlatformWebView> {
dynamic _controller;
bool _isLoading = true;
String _currentUrl = '';
final List<String> _history = [];
@override
void initState() {
super.initState();
_initWebView();
}
Future<void> _initWebView() async {
if (Platform.isAndroid) {
_controller = webview_flutter.WebViewController()
..setJavaScriptMode(webview_flutter.JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
webview_flutter.NavigationDelegate(
onNavigationRequest: (request) => webview_flutter.NavigationDecision.navigate,
onPageStarted: (_) => setState(() => _isLoading = true),
onPageFinished: (url) {
_currentUrl = url;
setState(() => _isLoading = false);
},
),
)
..loadRequest(Uri.parse(widget.url));
} else if (Platform.isWindows) {
_controller = webview_windows.WebviewController();
await _controller.initialize();
await _controller.setPopupWindowPolicy(webview_windows.WebviewPopupWindowPolicy.sameWindow);
_history.clear();
_history.add(widget.url);
_controller.url.listen((String newUrl) {
if (newUrl != _currentUrl && newUrl.isNotEmpty) {
if (_history.isEmpty || _history.last != newUrl) {
_history.add(newUrl);
}
setState(() => _isLoading = true);
_controller.loadUrl(newUrl).then((_) {
_currentUrl = newUrl;
});
}
});
_controller.loadingState.listen((webview_windows.LoadingState state) {
if (state == webview_windows.LoadingState.navigationCompleted) {
setState(() => _isLoading = false);
}
});
await _controller.loadUrl(widget.url);
_currentUrl = widget.url;
}
}
Future<bool> canGoBack() async {
if (Platform.isAndroid) {
return await (_controller as webview_flutter.WebViewController).canGoBack();
} else if (Platform.isWindows) {
return _history.length > 1;
}
return false;
}
Future<void> goBack() async {
if (Platform.isAndroid) {
await (_controller as webview_flutter.WebViewController).goBack();
} else if (Platform.isWindows) {
if (_history.length > 1) {
_history.removeLast();
final previousUrl = _history.last;
await _controller.loadUrl(previousUrl);
_currentUrl = previousUrl;
}
}
}
void reload() {
setState(() => _isLoading = true);
if (Platform.isAndroid) {
_controller?.reload();
} else if (Platform.isWindows) {
_controller?.loadUrl(_currentUrl);
}
}
@override
void dispose() {
if (Platform.isWindows) _controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
if (_controller != null)
Platform.isAndroid
? webview_flutter.WebViewWidget(controller: _controller)
: webview_windows.Webview(_controller),
if (_isLoading) const Center(child: CircularProgressIndicator()),
],
);
}
}
class NewsDetailPage extends StatelessWidget {
final News news;
const NewsDetailPage({super.key, required this.news});
@override
Widget build(BuildContext context) {
final webViewKey = GlobalKey<_PlatformWebViewState>();
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) return;
final state = webViewKey.currentState;
// 异步操作前先记录context
final currentContext = context;
if (state != null && await state.canGoBack()) {
await state.goBack();
} else {
if (currentContext.mounted) {
Navigator.pop(currentContext);
}
}
},
child: Scaffold(
appBar: AppBar(
title: Text(news.Title),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
final state = webViewKey.currentState;
// 异步操作前先记录context
final currentContext = context;
if (state != null && await state.canGoBack()) {
await state.goBack();
} else {
if (currentContext.mounted) {
Navigator.pop(currentContext);
}
}
},
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => webViewKey.currentState?.reload(),
),
],
),
body: PlatformWebView(key: webViewKey, url: news.Url),
),
);
}
}(c)新闻列表页:news_list_page.dart※
import 'package:flutter/material.dart';
import 'package:news_app/models/news_model.dart';
import 'package:news_app/pages/news_detail_page.dart';
import 'package:news_app/services/news_api_service.dart';
class NewsListPage extends StatefulWidget{
const NewsListPage({super.key});
@override
State<StatefulWidget> createState() => _NewsListPageState();
}
class _NewsListPageState extends State<NewsListPage> {
final NewsApiService _apiService = NewsApiService();
late Future<List<News>> _futureNews;
@override
void initState() {
super.initState();
_futureNews = _apiService.fetchNews();
}
Future<void> _refreshNews() async {
setState(() {
_futureNews = _apiService.fetchNews();
});
await _futureNews;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: RefreshIndicator(
onRefresh: _refreshNews,
child: FutureBuilder<List<News>>(
future: _futureNews,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(),);
} else if (snapshot.hasError) {
return Center(child: Text('加载失败:${snapshot.error}'),);
} else if (snapshot.hasData) {
List<News> newsList = snapshot.data!;
return ListView.builder(
itemCount: newsList.length,
itemBuilder: (context, index) {
News news = newsList[index];
return ListTile(
leading: CircleAvatar(
child: Text(news.displayRanking),
),
title: Text(news.Title),
trailing: (news.LabelUrl.isNotEmpty)
? Image.network(
news.LabelUrl,
width: 24,
height: 24,
)
: null,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetailPage(news: news),
));
},
);
},
);
} else {
return const Center(child: Text('没有数据'),);
}
},
),
),
);
}
}(d)注册页:register_page.dart※
import 'package:flutter/material.dart';
import 'package:news_app/services/storage_service.dart';
import 'package:news_app/models/account_model.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _isLoading = false;
Future<void> _handleRegister() async {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
try {
await Future.delayed(const Duration(seconds: 1));
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
// 检查账号是否已存在
final allAccounts = await StorageService.getAllAcounts();
if (allAccounts.any((a) => a.username == username)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('该账号已注册!')),
);
}
return;
}
// 注册成功:保存账号(默认登录状态)
await StorageService.saveAccount(AccountModel(
username: username,
password: password,
isLogin: true,
lastLoginTime: DateTime.now()
));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('注册成功,请返回登录!')),
);
Navigator.pop(context); // 返回登录页
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('注册失败:${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 600;
return Scaffold(
appBar: AppBar(
title: const Text('注册'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: Container(
width: isDesktop ? 500 : MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: '账号',
hintText: '请输入账号',
prefixIcon: Icon(Icons.account_circle),
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '账号不能为空';
return null;
},
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '密码',
hintText: '密码长度不得少于6位',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: const OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '密码不能为空!';
if (value.trim().length < 6) return '密码长度不能少于6位!';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: '确认密码',
hintText: '请再次输入密码',
prefixIcon: const Icon(Icons.lock_clock),
suffixIcon: IconButton(
icon: Icon(_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
border: const OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请确认密码!';
if (value.trim() != _passwordController.text.trim()) return '两次密码不一致!';
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('注册', style: TextStyle(fontSize: 18)),
),
),
],
),
),
),
),
);
}
}(e)密码重置页:reset_password_page.dart※
import 'package:flutter/material.dart';
import 'package:news_app/services/storage_service.dart';
import 'package:news_app/models/account_model.dart';
class ResetPasswordPage extends StatefulWidget {
const ResetPasswordPage({super.key});
@override
State<ResetPasswordPage> createState() => _ResetPasswordPageState();
}
class _ResetPasswordPageState extends State<ResetPasswordPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmNewPasswordController = TextEditingController();
bool _isLoading = false;
Future<void> _confirmReset() async {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
try {
final username = _usernameController.text.trim();
// 检查账号是否存在
final account = await StorageService.getAccountByUsername(username);
if (account == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('账号不存在!')),
);
}
return;
}
final newPassword = _newPasswordController.text.trim();
if (account.password == newPassword) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('新密码不能与旧密码相同!'))
);
}
return;
}
// 获取原账号并更新密码
AccountModel updateAccount = AccountModel(username: account.username, password: newPassword, isLogin: true, lastLoginTime: DateTime.now());
await StorageService.saveAccount(updateAccount);
// 重置成功
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('密码重置成功!请登录')),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('重置失败:${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 600;
return Scaffold(
appBar: AppBar(
title: const Text('重置密码'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: Container(
width: isDesktop ? 500 : MediaQuery.of(context).size.width * 0.9,
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: '账号',
prefixIcon: Icon(Icons.account_circle),
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请输入账号';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
obscureText: true,
controller: _newPasswordController,
decoration: InputDecoration(
labelText: '新密码',
hintText: '密码长度不得少于6位',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请输入新密码!';
if (value.trim().length < 6) return '密码长度不能少于6位!';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
obscureText: true,
controller: _confirmNewPasswordController,
decoration: InputDecoration(
labelText: '确认密码',
hintText: '请再次输入密码',
prefixIcon: Icon(Icons.lock_clock),
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[50],
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请确认新密码!';
if (value.trim() != _newPasswordController.text.trim()) return '两次密码不一致!';
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _confirmReset,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('确认重置', style: TextStyle(fontSize: 18)),
),
),
]
),
),
),
),
);
}
}(4)services目录※
该部分存放本地存储与读取服务、新闻数据获取服务。
(a)新闻数据获取服务:news_api_service.dart※
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:news_app/models/news_model.dart';
class NewsApiService {
final Dio _dio = Dio();
final String _apiKey = dotenv.env['SHWGIJ_KEY'] ?? '';
final String _baseUrl = 'https://api.shwgij.com/api/news/toutiao_news';
Future<List<News>> fetchNews() async {
try {
final response = await _dio.get(_baseUrl, queryParameters: {
'key': _apiKey
});
if (200 == response.statusCode) {
var newsResponse = NewsResponse.fromJson(response.data);
return newsResponse.data;
} else {
throw Exception('获取新闻失败');
}
} catch (e) {
throw Exception('获取新闻失败:$e');
}
}
}SHWGIJ_KEY为.env文件中新闻密钥的key,.env内容见后文。
(b)存储服务:storage_service.dart※
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:news_app/models/account_model.dart';
class StorageService {
// 存到SharedPreferences时的Key
static const String _accountKey = 'account_list';
// 获取全部账号列表
static Future<List<AccountModel>> getAllAcounts() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_accountKey) ?? '[]';
final List<dynamic> jsonList = json.decode(jsonString);
return jsonList.map((json) => AccountModel.fromJson(json)).toList();
}
// 根据用户名获取单个账号
static Future<AccountModel?> getAccountByUsername(String username) async {
final List<AccountModel> accounts = await getAllAcounts();
try {
return accounts.firstWhere((a) => a.username == username);
} catch (e) {
return null; // 账号不存在
}
}
// 获取所有已登录的账号,并且按最后登录时间排序后返回
static Future<List<AccountModel>> getAllLoggedInAccounts() async {
final accounts = await getAllAcounts();
return accounts
.where((account) => account.isLogin)
.toList()
..sort((a, b) {
if (a.lastLoginTime == null && b.lastLoginTime != null) return 1; /// a排在b后面
if (a.lastLoginTime != null && b.lastLoginTime == null) return -1; /// a排在b前面
if (a.lastLoginTime == null && b.lastLoginTime == null) return 0; /// 位置不变
return b.lastLoginTime!.isAfter(a.lastLoginTime!)? 1 : -1;
});
}
// 保存账号,已存在则覆盖,不存在则新增
static Future<void> saveAccount(AccountModel account) async {
final prefs = await SharedPreferences.getInstance();
final List<AccountModel> accounts = await getAllAcounts();
accounts.removeWhere((a) => a.username == account.username);
accounts.add(account);
final jsonString = json.encode(accounts.map((a) => a.toJson()).toList());
await prefs.setString(_accountKey, jsonString);
}
// 验证账号密码
static Future<AccountModel?> verifyAccount(String username, String password) async {
final allAccounts = await getAllAcounts();
try {
final account = allAccounts.firstWhere((a) => a.username == username);
return account.password == password ? account : null;
} catch (e) {
return null;
}
}
}(4)widgets目录※
该目录存放自定义组件,本项目中存放封装跨平台webview的组件。
(a)跨平台webview:platform_webview.dart※
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart' as webview_flutter;
import 'package:webview_windows/webview_windows.dart' as webview_windows;
class PlatformWebView extends StatefulWidget {
final String url;
const PlatformWebView({super.key, required this.url});
@override
State<PlatformWebView> createState() => _PlatformWebViewState();
}
class _PlatformWebViewState extends State<PlatformWebView> {
dynamic _controller;
bool _isLoading = true;
// 记录当前实际加载的 URL(避免重复加载)
String _currentUrl = '';
@override
void initState() {
super.initState();
_initWebView();
}
Future<void> _initWebView() async {
if (Platform.isAndroid) {
// Android 逻辑不变
_controller = webview_flutter.WebViewController()
..setJavaScriptMode(webview_flutter.JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
webview_flutter.NavigationDelegate(
onNavigationRequest: (request) => webview_flutter.NavigationDecision.navigate,
onPageStarted: (_) => setState(() => _isLoading = true),
onPageFinished: (_) => setState(() => _isLoading = false),
),
)
..loadRequest(Uri.parse(widget.url));
} else if (Platform.isWindows) {
_controller = webview_windows.WebviewController();
await _controller.initialize();
// 让新页面在当前窗口打开,不要新开窗口
await _controller.setPopupWindowPolicy(webview_windows.WebviewPopupWindowPolicy.sameWindow);
// 监听URL变化
_controller.url.listen((String newUrl) {
if (newUrl != _currentUrl && newUrl.isNotEmpty) {
setState(() => _isLoading = true);
_controller.loadUrl(newUrl).then((_) {
_currentUrl = newUrl; // 更新当前 URL 记录
});
}
});
// 监听加载状态(确保加载完成后隐藏 loading)
_controller.loadingState.listen((webview_windows.LoadingState state) {
if (state == webview_windows.LoadingState.navigationCompleted) {
setState(() => _isLoading = false);
}
});
// 初始化加载初始 URL
await _controller.loadUrl(widget.url);
_currentUrl = widget.url;
}
}
void reload() {
setState(() => _isLoading = true);
if (Platform.isAndroid) {
_controller?.reload();
} else if (Platform.isWindows) {
_controller?.loadUrl(_currentUrl);
}
}
@override
void dispose() {
if (Platform.isWindows) _controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
if (_controller != null)
Platform.isAndroid
? webview_flutter.WebViewWidget(controller: _controller)
: webview_windows.Webview(_controller),
if (_isLoading) const Center(child: CircularProgressIndicator()),
],
);
}
}- 将
webview_windows(Windows下URL渲染组件)和webview_flutter(Android下URL渲染组件)进行封装,以实现能够跨平台支持。
(5)程序入口文件:main.dart※
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:news_app/pages/login_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 加载.env文件
await dotenv.load(fileName: '.env');
runApp(const NewsApp());
}
class NewsApp extends StatelessWidget {
const NewsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '今日头条热搜新闻榜',
theme: ThemeData(
primarySwatch: Colors.blue
),
home: const LoginPage(),
);
}
}(6)密钥存放文件:.env※
SHWGIJ_KEY=项目初始化中注册的API的密钥四、运行效果※

登录页

添加已有账号

注册页

密码重置页

新闻列表页

新闻详情页