採用はこちら!

Shinonome Tech Blog

株式会社Shinonomeの技術ブログ
7 min read

[Flutter] 学内手渡しのフリマアプリを作っている話

教科書の売買にかかる送料や梱包の手間を抑えられるフリマアプリを開発しています。 #Flutter #MVVM #Riverpod #DRF #FCM

自己紹介

PlayGroundでmobileコースの運営統括を務めているko_chaです。コミュニティの活動紹介として、同じ大学のメンバー数名で開発中のサービスを紹介させてください!

なんでこのアイデア?

解決したい課題は?

A. 毎年同じはずなのに新品の教科書を買わされること

(以下文句の垂れ流しです)

学生を経験したことのある皆様なら共感して頂けると思うのですが、

教科書って高くないですか??

弊学では学期の初めに、某書店の専用webページで購入するようにとのお達しが来ます。ご丁寧に「第一回目の授業から教科書を持参することが重要です」といった文言を添えて。

律儀に全部新品で買っていると、平気で1万円を超えてきます...涙

今あるフリマアプリではだめなの?

A. 送料、梱包の手間を省きたい

今でも私のように安く済ませたい学生はフリマアプリで購入します。

そこで気になったのが、発送元が遠いという点です。近くに先輩がいるのにわざわざ遠くの人から買う必要ってある...?と感じてしまいました。

同じようなサービスはないの?

A. ない。アメリカでは似たサービスがあった。

日本にも教科書専用フリマアプリはあったようですが、「学内手渡し」はなさそうでした。

Textrade – 大学生向け教科書フリマアプリ
Textradeとは Textradeとは教科書を安く購入したい学生と教科書を売りたい学生を繋げるサービスです...
同じキャンパス内で大学教科書の売買取引ができるアプリ -JNEWS-

https://twitter.com/locolistapp

作っているもの

UIはありきたりなフリマアプリを踏襲しています。

ログイン画面&商品一覧画面

商品詳細画面

キャンパス単位で受け取り場所が設定できます。初期リリースでは弊学3キャンパスのみ対応予定です。

使用技術

少しだけ技術面のお話です。

Freezed

モデルの作成に使用しました。

DRF

BEは正直あまり理解できていません。3人チームで開発してくれました!

FCM

購入されたとき、コメントが投稿された時に送信されます。

フローは以下の通り

アプリ起動時にデバイスIDを取得→BEに送信→アカウントにIDを紐付け(複数端末対応)→発火→取得したIDを元にBEが通知を送信

設計

見よう見まねですがMVVMで設計してみました。

~modelにモデル, ~viewにUI, ~view_modelにロジック, ~repositoryに通信部分を書いています。

ページの最後に一例として商品一覧画面のコードを載せておきます。

主な参考資料

【Flutter】Riverpod v2を使ってQiitaアプリを作ってみた | 株式会社ゼロイチ
こんにちは。バックエンドエンジニア+アプリエンジニアの弓場です。 今回は、Flutterの状態管理パッケージであるRiverpodを使って、Qiitaアプリを作ってみました。 作ったもの QiitaA
Flutter×Django(DRF)で画像をアップロードする方法 - Qiita
バックエンドにDjango Rest Frameworkを使用して、Flutterで画像を扱う方法をまとめますなお、Flutterの実装では状態管理のProviderを使用していますバックエンド…
Flutter × FCMでプッシュ通知を実装する

今後の予定

  • Firebase App Distributionにてテスト配布後修正←今ココ
  • デザインのメンバーが加入してくれたため、UI,UXの改善をしたい!
  • リリースは2月頃
  • 売れなかった商品をアマゾン、他のフリマサイトに売る機能(検討中)

コメント

みんなでモノづくりをするのはやっぱり楽しい!作ったモノを褒めてもらえるというのは一番の活力になります🙌

SNS

https://twitter.com/ksakai117

kota78 - Overview
kota78 has 32 repositories available. Follow their code on GitHub.

商品一覧画面のコード

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:trade_app/models/photo_model.dart';

part 'item_model.freezed.dart';

part 'item_model.g.dart';

@freezed
class Item with _$Item {
  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory Item({
    @Default("") String id,
    @Default("") String seller,
    @Default("") String receivableCampus,
    @Default([Photo(order: 1, photoPath: "")]) List<Photo> images,
    @Default(false) bool isLikedByCurrentUser,
    @Default("") String listingStatus,
    @Default(0) int price,
    @Default("") String name,
    @Default("") String description,
    @Default("") String condition,
    @Default("") String writingState,
    DateTime? createdAt,
    DateTime? updatedAt,
    @Default("") String buyer,
  }) = _Item;

  factory Item.fromJson(Map<String, dynamic> json) => _$ItemFromJson(json);
}
item_model.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trade_app/component/grid_view_component.dart';
import 'package:trade_app/component/on_going_bottom.dart';
import 'package:trade_app/config/user_data_provider.dart';
import 'package:trade_app/models/item_model.dart';
import 'package:trade_app/views/error/network_error_view.dart';
import 'package:trade_app/views/top/item_grid_view_model.dart';

class ItemGridView extends ConsumerStatefulWidget {
  const ItemGridView({
    required this.provider,
    super.key,
  });

  final StateNotifierProvider<ItemsNotifier, AsyncValue<List<Item>>> provider;

  @override
  ConsumerState createState() => _ItemGridViewState();
}

class _ItemGridViewState extends ConsumerState<ItemGridView> {

  @override
  void initState() {
    ref.read(widget.provider.notifier).fetch();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final AsyncValue<List<Item>> asyncValue = ref.watch(widget.provider);
    final userData = ref.read(userDataProvider);
    return NotificationListener<ScrollEndNotification>(
      child: Scrollbar(
        child: CustomScrollView(
          primary: false,
          restorationId: 'articles',
          physics: const AlwaysScrollableScrollPhysics(),
          slivers: <Widget>[
            CupertinoSliverRefreshControl(
              onRefresh: () async {
                ref.read(widget.provider.notifier).refresh();
              },
            ),
            asyncValue.when(
              data: (items) {
                return GridViewComponent(items: items, userData: userData);
              },
              error: (error, stacktrace) {
                if (asyncValue.hasValue) {
                  return GridViewComponent(items: asyncValue.value!, userData: userData,);
                }
                debugPrint(error.toString());
                return SliverToBoxAdapter(
                  child: NetworkErrorView(onRetry: () {
                    ref.read(userDataProvider.notifier).refreshAccessToken();
                    ref.read(widget.provider.notifier).refresh();
                  }),
                );
              },
              loading: () => const SliverToBoxAdapter(
                  child: Center(child: CupertinoActivityIndicator())),
            ),
            OnGoingBottom(
              asyncValue: asyncValue,
            ),
          ],
        ),
      ),
      onNotification: (notification) {
        // 一番下までスクロールしたとき
        if (notification.metrics.extentAfter == 0) {
          // 追加でローディング
          ref.read(widget.provider.notifier).loadMore();
          return true;
        }
        return false;
      },
    );
  }
}
item_grid_view.dart
import 'package:flutter/material.dart';
import 'package:trade_app/models/item_model.dart';
import 'package:trade_app/models/user_data_model.dart';
import 'package:trade_app/views/item_detail/item_detail_view.dart';
import 'package:trade_app/views/top/item_card.dart';

class GridViewComponent extends StatelessWidget {
  const GridViewComponent({Key? key, required this.items, required this.userData}) : super(key: key);
  final List<Item> items;
  final UserData userData;

  @override
  Widget build(BuildContext context) {
    if (items.isEmpty) {
      return const SliverToBoxAdapter(
        child: Center(
          child: Text('検索結果は0件です。'),
        ),
      );
    } else {
      return SliverGrid(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
        ),
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            return ItemCard(
              item: items[index],
              userData: userData,
              navigate: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (context) => ItemDetailView(items[index], userData),
                  ),
                );
              },
            );
          },
          childCount: items.length,
        ),
      );
    }
  }
}
grid_view_component.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trade_app/constant/url.dart';
import 'package:trade_app/models/item_model.dart';
import 'package:trade_app/repository/item_repository.dart';

//商品全体
final itemsProvider =
    StateNotifierProvider<ItemsNotifier, AsyncValue<List<Item>>>((ref) {
  return ItemsNotifier(ref.read(itemRepositoryProvider), '${Url.apiUrl}items/');
});

final itemRepositoryProvider = Provider<ItemRepository>((ref) {
  return ItemRepository();
});

//〇〇商品一覧データを管理する雛形
class ItemsNotifier extends StateNotifier<AsyncValue<List<Item>>> {
  ItemsNotifier(this._repository, this.apiUrl)
      : super(const AsyncLoading<List<Item>>()) {
    // Providerが初めて呼び出されたときに実行
    fetch();
  }

  final String apiUrl;

  int page = 1;
  String name = '';
  bool isShowSoldItem = false;

  final ItemRepository _repository;

  void changeName(String str) {
    page = 1;
    name = str;
  }

  Future<void> fetch({
    bool isLoadMore = false,
  }) async {
    final url =
        isShowSoldItem ? "$apiUrl?purchased=true" : "$apiUrl?purchased=false";
    state = await AsyncValue.guard(() async {
      final items = await _repository.fetchItems(name, page, url);
      return [if (isLoadMore) ...state.value ?? [], ...items];
    });
  }

  void loadMore() {
    // ローディング中にローディングしないようにする
    if (state == const AsyncLoading<List<Item>>().copyWithPrevious(state)) {
      return;
    }
    // 取得済みのデータを保持しながら状態をローディング中にする
    state = const AsyncLoading<List<Item>>().copyWithPrevious(state);
    page++;
    fetch(isLoadMore: true);
  }

// 取得済みのデータを保持しながら状態をローディング中にする
  void refresh() {
    state = const AsyncLoading<List<Item>>().copyWithPrevious(state);
    page = 1;
    fetch();
  }

  //購入済み商品を表示するかどうかを返すgetter
  bool getIsShowSoldItem() => isShowSoldItem;

  //購入済み商品を表示するかどうかを切り替えるsetter
  //切り替えた後再読み込み
  void setIsShowSoldItem(bool isShow) async {
    isShowSoldItem = isShow;
    page = 1;
    fetch();
  }

//いいねのon/off
  void toggleLike(String id, bool isLike) {
    if (state.value != null) {
      final targetItemIndex =
          state.value!.indexWhere((element) => element.id == id);
      if (targetItemIndex >= 0) {
        state.value![targetItemIndex] = state.value![targetItemIndex]
            .copyWith(isLikedByCurrentUser: isLike);
      }
    }
  }
}
item_grid_view_model.dart
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:trade_app/constant/url.dart';
import 'package:trade_app/models/item_model.dart';
import 'package:trade_app/repository/other_repository.dart';

class ItemRepository {
  static Future<void> patchItemData(String itemId, String endPoint) async {
    var dio = Dio();
    dio.interceptors.add(LogInterceptor());
    dio = await OtherRepository.addCookie(dio);
    try {
      await dio.put('${Url.apiUrl}items/$itemId/$endPoint/');
    } catch (e) {
      throw Exception(e);
    }
  }

//商品一覧データを取得
  Future<List<Item>> fetchItems(String name, int page, String url) async {
    var dio = Dio();
    dio = await OtherRepository.addCookie(dio);
    dio.interceptors.add(LogInterceptor());
    try {
      final response = await dio.get(
        url,
        queryParameters: {
          "name": name,
          "page": page,
        },
      );
      Map<String, dynamic> jsonData = response.data;
      debugPrint(response.data.toString());
      debugPrint(jsonData.toString());
      return (jsonData['results'] as List)
          .map((itemData) => Item.fromJson(itemData as Map<String, dynamic>))
          .toList();
    } catch (e) {
      if ((e is DioException) && e.response!.statusCode == 404) {
        return [];
      }
      throw Exception(e);
    }
  }
//出品
  static Future<void> exhibitItem(
      Map<String, dynamic> itemData, List<File> imageFiles) async {
    var dio = Dio();
    dio.interceptors.add(LogInterceptor());
    const url = '${Url.apiUrl}items/create/';
    Map<String, dynamic> imageData = {};
    dio = await OtherRepository.addCookie(dio);
    int i = 1;
    for (var img in imageFiles) {
      imageData.addAll({'image_$i': MultipartFile.fromFileSync(img.path)});
      i++;
    }
    final formData = FormData.fromMap({
      ...itemData,
      ...imageData,
    });
    try {
     await dio.post(url, data: formData);
    } catch (e) {
      throw Exception(e);
    }
  }
}
item_repository.dart