flutter小白是如何在一周内用chatGPT开发一款App的

创作初衷

这篇文章创作的初衷,只是为了写一个有关日历类的软件供自己使用,考虑到自己从来还没有使用flutter正式创作一个app,因此磨刀霍霍想试一试。

至于为什么要做一款日历软件,因为发现市面上的关于万年历的软件都有很多广告,想着自己也能做,就做个给自己用。同时里面包含了额外的模块,包括万年历、天气以及小常识等等。。。

创作过程

由于自己是flutter小白,对Dart语言也是一知半解,因此想在快速的时间内去完成一款app,就可能得翻破flutter官网相关的文档,效率不见得很高,因此主要结合chatGPT给我做知识扫盲以及方案选型建议。

比如我让chatGPT给我生成一段日历的核心逻辑:

然后不断加以修正,比如可以支持从星期日开始:

虽然不是很熟悉dart语法,但是并不是很影响我读懂代码。一般做过React或者声明式语言(android compose/swift)语言的人,上手flutter会相当快。

chatGPT在问答的过程中,也会说一些胡话,比如我在做天气模块的时候,需要实现一个向上滚动,标题部分自动缩小,并保证滚动条在标题下方滚动的功能,但是chatGPT并不能给我正确的回答,准确的说,它能给我回答,但是大多都是它胡诌的。

所以在使用这类AI工具的时候,需要自己识别它给出的到底是不是一个正确的答案,可以不断去试错,切不可一条路走到黑,无脑去相信。

关于如何精准使用chatGPT做问答、搜索、创作,以及源码解析,我司邮件每天都有讨论,欢迎加入探讨。

说(遇)说(到)重(的)点(坑)

几个重要的库或选择

至于为什么这么选择?我都是在chatGPT中问出来的,毕竟小白首先得知道方向在哪里,然后根据给出的提示去官方文档进行比较。

比如在选择使用哪个天气时,我首先从chatGPT给我的推荐中去官网查看,看是否能够满足我的需求

  • 免费API (或者说调用次数在多少次内免费)
  • 是否提供当天的详细天气情况
  • 是否提供一天24小时的天气走势
  • 是否提供7天之内的详细情况

经过比较之后,我发现上述的都不是特别合适,基本上提供7天以上的就不能免费订阅了,所以在此基础上,我就会再加上一些关键词,比如 "免费API", "7天天气详情"等等。

底部导航栏动画

原本采用的是flutter默认提供的导航栏,后来想想怎么也的折腾一番。但是这一折腾不打紧,导致我后面路由的设计全改变了。

页面有4个导航tab,所以我最开始采用了4个路由,分别对应4个tab

class Routes {
  static String calendar = "/calendar";
  static String weather = "/weather";
  static String sense = "/sense";
  static String settings = "/settings";
  // ...
}

这样安于现状老老实实切换是木有问题的,但是我想在切换的时候加点动画,类似与这样的,就不work了:

原因是这个组件在路由切换的时候,都会重新渲染一份,所以动画肯定是没有的,无奈之下,就提取了一个公共页,采用分支逻辑hide/show,来做tab页面的切换

Scaffold(
  appBar: getAppBar(selectedIndex, context),
  body: getBody(selectedIndex, senseState),
  bottomNavigationBar: renderBottomNavigationBar(
    context,
    selectedIndex,
    (index) {
      setState(() {
        selectedIndex = index;
      });
    },
  ),
  floatingActionButton: getFloatingActionButton(selectedIndex, homeState),
);

Widget getBody(int index, SenseState senseState) {
  switch (index) {
    case 0:
      return const Calendar();
    case 1:
      return const Weather();
    case 2:
      return CommonSense(senseState: senseState);
    case 3:
      return const Settings();
    default:
      return const Calendar();
  }
}

数据预加载

我做的这个demo里面,由于需要展示天气信息,所以在显示日历的时候,就可以进行天气信息的预加载了。

我的具体做法是在main.dart中,在weatherState初始化后就立即将天气信息获取然后塞入state中,这样在我切换到天气页面的时候,就可以获取到详细的数据了。【可能有更加好的办法💐】

// main.dart
final position = await _determinePosition();
final weatherState = WeatherState(position);
weatherState.getWeatherInfo();

// weather_state.dart
Future<void> getWeatherInfo() async {
  final location = "${position.longitude},${position.latitude}";
  final responses = await loadAllWeatherData(location);

  if (responses.isNotEmpty && responses.length == 4) {
    final weatherLocation = responses[0] as WeatherLocation;
    final weatherNow = responses[1] as WeatherNow;
    final weatherHourly = responses[2] as WeatherTwentyFourHours;
    final weatherDaily = responses[3] as WeatherSevenDays;
    setWeatherInfo(
      weatherLocation.location[0],
      weatherNow.now,
      weatherHourly.hourly,
      weatherDaily.daily,
    );
  }
}

日历月份切换

采用了flutter_swiper这个组件来做左右日历的滑动,但是要想很丝滑(当滑动下一个月的时候,能够立马看到数据),就需要把提前将下一个月的日历详情全部生成出来,最开始想直接生成几年的数据,想想还是太粗暴了,所以只是生成了前一个月以及后一个月的数据。

var list = [prevCalendarDates, calendarDates, nextCalendarDates];

Swiper(
  index: 1,
  loop: false,
  duration: 1,
  itemCount: list.length,
  onIndexChanged: (int index) {},
  itemBuilder: (BuildContext context, int index) {}
)

可以看到,我默认在swiper中显示的索引是1,这样显示的就是当前月份的日历信息。但是这样也有一个问题,由于这个swiper组件自带从左到右的动画,滑到上个月还好,但是滑到下一个月,就会有一个先向左再向右的动画突兀,所以我将duration的值改为了1,就是避免使用swiper的动画。

关于本地存储

最开始其实没有打算用到服务器来进行api请求,毕竟最开始的打算只是做一个简简单单的万年历,所以所有的事件、提醒信息都打算存储在本地,采用sqlite关系型数据库来解决。

后来需求膨胀(加了常识模块),发现这玩意就不好使了,因为常识模块需要添加的字段比较多,并不像日历部分只需要加几个简单的字段,而且也不会特别多,所以不得已又迫使搞出个后台来。

其间纠结了很久,要不要就统一使用本地数据库呢?常识这块搞一个本地后台管理就好了,连接到august.db文件,然后进行增删查改也不是不能接受,后来发现有点虚,毕竟我是想在自己的手机上run的,难道每次同步还得把自己电脑后台服务打开,想想都有点麻烦。

所以后来还是把常识这块部署到了生产环境,日历事件部分采用的本地数据库,这样会快一点进行每天日历事件的初始化。所以整个一块的改动也是反反复复的。

日历事件采用本地sqlite

class DatabaseProvider {
  // ...

  Future<Database> _initDatabase() async {
    final databasesPath = await getDatabasesPath();
    final path = join(databasesPath, 'august.db');
    Logger.d("database path: $path");

    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
            CREATE TABLE IF NOT EXISTS ${CalendarDB.calendarEvent} (
              id TEXT,
              dateId TEXT,
              title TEXT,
              content TEXT,
              date INTEGER,
              lunarDate TEXT,
              isCycle INTEGER,
              cycleBy INTEGER,
              createTime INTEGER,
              modifyTime INTEGER,
              deleted INTEGER
            )
          ''');
      },
    );
  }
}

常识部分调远端api

final baseUrl = "${dotenv.env['SENSE_BACKEND_URL']}/api/senses";

Future<List<CommonSense>> getCommonSenseByPage(
    {int page = 1, int pageSize = 20}) async {
  final response = await Http.get(
    "$baseUrl/",
    params: {'page': page, 'pageSize': pageSize},
  );

  return SenseResponse.fromJson(response.data).data;
}

然后至于本地的事件提醒数据,打算定期备份,即把本地的数据库文件上传至服务器。【TODO】

天气滑动动画

为了实现上面的动画,chatGPT多少是在这块犯浑了,尽管给我指引了采用sliverAppBar来实现此功能;

但是当向上滑动时,滚动条默认会从屏幕的最顶端开始滑动,这就导致了滑动的内容会透过缩小后的文字 [贴图中 -> 旧金山 多云 13°C]显示在下面,再次询问如何解决时,给我的总是错误的答案,看来还是不能轻信啊😁

后来google了解决办法,采用了CustomClipper,这里贴一下:

import 'package:flutter/material.dart';
import 'dart:math' as math;

class CustomClipperContainer extends StatelessWidget {
  final Widget child;

  const CustomClipperContainer({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    return ClipRect(
      clipper: MyCustomClipper(
        clipHeight: MediaQuery.of(context).size.height - 220,
      ),
      child: child,
    );
  }
}

class MyCustomClipper extends CustomClipper<Rect> {
  final double clipHeight;

  MyCustomClipper({required this.clipHeight});

  @override
  getClip(Size size) {
    double top = math.max(size.height - clipHeight, 0);
    Rect rect = Rect.fromLTRB(0.0, top, size.width, size.height);
    return rect;
  }

  @override
  bool shouldReclip(CustomClipper oldClipper) {
    return false;
  }
}

// 使用
CustomClipperContainer(
  child: ListView(
    padding: EdgeInsets.zero,
    shrinkWrap: true,
    physics: const NeverScrollableScrollPhysics(),
    children: const [
      HourlyForecast(),
      SevenDayForecast(),
      CurrentDetail(),
    ],
  ),
)

天气背景映射

由于天气背景我采用了flutter_weather_bg这个库,里面包括了一系列的天气背景动画,比如下雨、雷电、下雪等等动画场景,但是由于我使用了和风天气,返回的api里面并不能很好的和这个库搭配起来,所以这里不得不做映射处理。

WeatherType getWeatherTypeBy(String weatherText, String icon) {
  if (weatherText == '晴') {
    if (icon == '100') {
      return WeatherType.sunny;
    } else {
      return WeatherType.sunnyNight;
    }
  } else if (weatherText.contains('云')) {
    if (icon == '101' || icon == '102' || icon == '103') {
      return WeatherType.cloudy;
    } else {
      return WeatherType.cloudyNight;
    }
  } else if (weatherText == '阴') {
      // ...
  }
  // ...
}

按照道理讲,关于天气这一块所有的api请求,最好还是要走一层后端,如果再做厚一点,应该有个BFF层来专门处理数据的组装、转发等场景。比如类似这样的mapping,以及获取天气数据的信息等请求就可以由BFF给我返回了,这样做的好处是,将更多的细节封装到了内部,前端只需要更加纯粹地显示数据就好了,如果后续有改动,比如我的天气从和风API转成了XXX API,前端部分可以完全不用再改动了。

但是由于我是后来才想起我要做个常识模块,那个时候才引入了一个后台,所以前面的就懒得整了。【TODO】

滑动后退失效了

当我快要完成我的demo时,我突然想起来,试试滑动后退,发现怎么也不起作用。后来想想问题应该是出在了路由上,于是去网上扒了扒

找到个issue

将默认的TransitionType设为TransitionType.cupertino就解决了。

主题部分

准备了两套颜色,明亮色以及暗黑色【颜色部分可能还是得有设计师来,这块真是搞得我头痛】,然后使用ThemeData进行封装,然后在MaterialApp上进行设置。

MaterialApp(
  debugShowCheckedModeBanner: false,
  theme: globalState.isDarkMode ? darkTheme : lightTheme,
  onGenerateRoute: Application.router.generator,
);

将用户的偏好存储在sharedPreferences中,这样当用户下次再次进入app时,就能记住上次是选择了哪个主题。

// user_preference.dart
class UserPreference {

  static Future<bool> getThemeMode() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    var isDarkMode = prefs.getBool(isDarkModeText);
    return isDarkMode ?? false;
  }

  static Future<void> updateThemeMode(bool isDarkMode) async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    prefs.setBool(isDarkModeText, isDarkMode);
  }
}

// global_state.dart
class GlobalState extends ChangeNotifier {
  bool isDarkMode = true;

  GlobalState(this.isDarkMode);

  void toggleTheme() async {
    isDarkMode = !isDarkMode;
    UserPreference.updateThemeMode(isDarkMode);
    notifyListeners();
  }
}

还有一些可以讲讲

使用dotenv获取环境变量

final apiKey = dotenv.env['WEATHER_API_KEY'];

好处是配置与使用隔离,这样也安全一点。

使用Json To Dart插件生成model

网上也有使用json_serializable来实现序列化与反与反序列化的,但我个人觉得小项目还是这个插件好用,因为这个库会将文件分割成两个部分。

flutter_native_splash生成splash页面

使用这个库flutter_native_splash,详细用法参看官方文档。

# 更新splash页面,更新玩颜色以及背景图片后,运行以下命令
flutter clean && flutter pub get && flutter pub run flutter_native_splash:create

后端部分

分为august-server以及august-admin,server主要提供api服务,admin提供后台数据管理,admin的模版是从网上嫖的。感兴趣可以自己去看看 vue-manage-system

数据库采用了postgres,使用docker-compose做了服务编排,这里贴一下,感兴趣自己看看

version: '3.8'  
  
services:  
    postgresdb:  
        image: postgres:14.8  
        restart: unless-stopped  
        env_file: ./.env  
        environment:  
            - POSTGRES_DB=$POSTGRES_DATABASE  
            - POSTGRES_USER=$POSTGRES_USER  
            - POSTGRES_PASSWORD=$POSTGRES_PASSWORD  
        healthcheck:  
            test: pg_isready -U postgres  
        ports:  
            - $POSTGRES_LOCAL_PORT:$POSTGRES_DOCKER_PORT  
        volumes:  
            - ./data:/var/lib/postgresql/data  
    app:  
        depends_on:  
            postgresdb:  
        condition: service_healthy  
        build: ./august-server  
        restart: unless-stopped  
        env_file: ./.env  
        ports:  
            - $NODE_LOCAL_PORT:$NODE_DOCKER_PORT  
        environment:  
            - DB_HOST=postgresdb  
            - DB_USER=$POSTGRES_USER  
            - DB_PASSWORD=$POSTGRES_PASSWORD  
            - DB_NAME=$POSTGRES_DATABASE  
            - DB_PORT=$POSTGRES_DOCKER_PORT  
        stdin_open: true  
        tty: true  

    admin:  
        depends_on:  
            - app  
        build: ./august-admin  
        restart: unless-stopped  
        env_file: ./.env  
        ports:  
            - $ADMIN_LOCAL_PORT:$ADMIN_LOCAL_PORT  
        environment:  
            - PROXY_PROT=$NODE_DOCKER_PORT

需要提一点的是,app服务需要完全等数据库服务启动之后,才能请求数据,否则直接报错。所以这块,我加了healthcheck(最开始我一直以为是mysql的问题,后来发现切换成postgres后依然有问题😁😄)。

总结

好了至此为止,想说的就已经说完了,整个功能来说相对简单,当然也躺了不少的坑,仅此供学习交流。

另外,针对一门新的技术,chatGPT能给你很好的入门指导,虽然胡说的不一定准,但是不说肯定是啥都不知道😂😂

最后贴贴代码仓库:

仅供学习交流,勿商用!!!

热门相关:峡谷正能量