30天计划第18天-业务App整合(综合练习)

-
-
2025-12-02 14:04
  • 目标:把前 10 天内容整合,做一个 “简易新闻 App”

  • 功能清单

    1. 登录页(本地存储记住账号)

    2. 新闻列表页(Dio 请求数据、加载状态、下拉刷新)

    3. 新闻详情页(接收列表页传值、显示内容)

  • 实践任务

    1. 完成 App 核心功能,确保 Android/iOS/Web/ 桌面端能正常运行

    2. 修复适配问题(如 Web 端列表滚动、桌面端窗口大小)

注:本章只覆盖安卓和桌面端。

一、接口准备

        app新闻数据使用今日头条热搜数据,api使用SHWGIJ APIhttps://api.shwgij.com/),接口需要token认证,每日一千次免费调用,token获取步骤:

  1. 注册并登录账号

  2. 登录成功后会自动进入用户控制台,左侧切换到秘钥管理

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

二、项目初始化与环境配置

  • 新建项目

    在终端执行如下命令:

    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的密钥

四、运行效果

登录页

添加已有账号

注册页

密码重置页

新闻列表页

新闻详情页


目录