自己紹介
PlayGroundでmobileコースの運営統括を務めているko_chaです。コミュニティの活動紹介として、同じ大学のメンバー数名で開発中のサービスを紹介させてください!
なんでこのアイデア?
解決したい課題は?
A. 毎年同じはずなのに新品の教科書を買わされること
(以下文句の垂れ流しです)
学生を経験したことのある皆様なら共感して頂けると思うのですが、
教科書って高くないですか??
弊学では学期の初めに、某書店の専用webページで購入するようにとのお達しが来ます。ご丁寧に「第一回目の授業から教科書を持参することが重要です」といった文言を添えて。
律儀に全部新品で買っていると、平気で1万円を超えてきます...涙
今あるフリマアプリではだめなの?
A. 送料、梱包の手間を省きたい
今でも私のように安く済ませたい学生はフリマアプリで購入します。
そこで気になったのが、発送元が遠いという点です。近くに先輩がいるのにわざわざ遠くの人から買う必要ってある...?と感じてしまいました。
同じようなサービスはないの?
A. ない。アメリカでは似たサービスがあった。
日本にも教科書専用フリマアプリはあったようですが、「学内手渡し」はなさそうでした。
![](https://textrade.org/wp-content/uploads/2021/04/banner2-1.png)
![](https://www.jnews.com/world/2016/016_scr01.jpg)
https://twitter.com/locolistapp
作っているもの
UIはありきたりなフリマアプリを踏襲しています。
ログイン画面&商品一覧画面
![](http://blog.shinonome.io/content/images/2023/12/--------------2-.png)
商品詳細画面
![](http://blog.shinonome.io/content/images/2023/12/--------------1-.png)
キャンパス単位で受け取り場所が設定できます。初期リリースでは弊学3キャンパスのみ対応予定です。
使用技術
少しだけ技術面のお話です。
Freezed
モデルの作成に使用しました。
DRF
BEは正直あまり理解できていません。3人チームで開発してくれました!
FCM
購入されたとき、コメントが投稿された時に送信されます。
フローは以下の通り
アプリ起動時にデバイスIDを取得→BEに送信→アカウントにIDを紐付け(複数端末対応)→発火→取得したIDを元にBEが通知を送信
設計
見よう見まねですがMVVMで設計してみました。
~modelにモデル, ~viewにUI, ~view_modelにロジック, ~repositoryに通信部分を書いています。
ページの最後に一例として商品一覧画面のコードを載せておきます。
主な参考資料
![](https://zeroichi.biz/img/ogp.jpg)
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fcdn.qiita.com%2Fassets%2Fpublic%2Farticle-ogp-background-9f5428127621718a910c8b63951390ad.png?ixlib=rb-4.0.0&w=1200&mark64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTkxNiZoPTMzNiZ0eHQ9Rmx1dHRlciVDMyU5N0RqYW5nbyVFRiVCQyU4OERSRiVFRiVCQyU4OSVFMyU4MSVBNyVFNyU5NCVCQiVFNSU4MyU4RiVFMyU4MiU5MiVFMyU4MiVBMiVFMyU4MyU4MyVFMyU4MyU5NyVFMyU4MyVBRCVFMyU4MyVCQyVFMyU4MyU4OSVFMyU4MSU5OSVFMyU4MiU4QiVFNiU5NiVCOSVFNiVCMyU5NSZ0eHQtY29sb3I9JTIzMjEyMTIxJnR4dC1mb250PUhpcmFnaW5vJTIwU2FucyUyMFc2JnR4dC1zaXplPTU2JnR4dC1jbGlwPWVsbGlwc2lzJnR4dC1hbGlnbj1sZWZ0JTJDdG9wJnM9OTc5NzYyODMxZGY2MTc0ODI4MTI5MTE5MDU2ZThlNzI&mark-x=142&mark-y=112&blend64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTYxNiZ0eHQ9JTQwa29zZWlkYWlraSZ0eHQtY29sb3I9JTIzMjEyMTIxJnR4dC1mb250PUhpcmFnaW5vJTIwU2FucyUyMFc2JnR4dC1zaXplPTM2JnR4dC1hbGlnbj1sZWZ0JTJDdG9wJnM9MGFjMGM2ZDk0YmZhZDZlMGY1NjI5YTk5ODU1OTM2NTE&blend-x=142&blend-y=491&blend-mode=normal&s=7471ac73b5ac2cde0e869782869c429d)
![](https://res.cloudinary.com/zenn/image/upload/s--87yENA4J--/c_fit%2Cg_north_west%2Cl_text:notosansjp-medium.otf_55:Flutter%20%C3%97%20FCM%E3%81%A7%E3%83%97%E3%83%83%E3%82%B7%E3%83%A5%E9%80%9A%E7%9F%A5%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B%2Cw_1010%2Cx_90%2Cy_100/g_south_west%2Cl_text:notosansjp-medium.otf_34:Daigo%20Wakabayashi%2Cx_220%2Cy_108/bo_3px_solid_rgb:d6e3ed%2Cg_south_west%2Ch_90%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyL2Y0ODUxNDkwOTcuanBlZw==%2Cr_20%2Cw_90%2Cx_92%2Cy_102/co_rgb:6e7b85%2Cg_south_west%2Cl_text:notosansjp-medium.otf_30:Flutter%E5%A4%A7%E5%AD%A6%2Cx_220%2Cy_160/bo_4px_solid_white%2Cg_south_west%2Ch_50%2Cl_fetch:aHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL3plbm4tdXNlci11cGxvYWQvYXZhdGFyLzlkYjBiMTNjMmUuanBlZw==%2Cr_max%2Cw_50%2Cx_139%2Cy_84/v1627283836/default/og-base-w1200-v2.png)
今後の予定
- Firebase App Distributionにてテスト配布後修正←今ココ
- デザインのメンバーが加入してくれたため、UI,UXの改善をしたい!
- リリースは2月頃
- 売れなかった商品をアマゾン、他のフリマサイトに売る機能(検討中)
コメント
みんなでモノづくりをするのはやっぱり楽しい!作ったモノを褒めてもらえるというのは一番の活力になります🙌
SNS
商品一覧画面のコード
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);
}
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;
},
);
}
}
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,
),
);
}
}
}
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);
}
}
}
}
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);
}
}
}