CONTENT
ここから
前のページでは、投稿の新しさ、反応数、増加速度、複数情報源での掲載状況から、0点から100点の話題度を計算しました。
このページでは、Supabaseへ保存されたtrend_itemsをFlutterアプリから読み込みます。
完成する画面では、次の操作ができるようにします。
トレンド一覧
├─ 話題度が高い順に表示する
├─ 新しい順に表示する
├─ Mastodonだけに絞り込む
├─ Blueskyだけに絞り込む
├─ RSSだけに絞り込む
├─ YouTubeだけに絞り込む
├─ キーワードで検索する
├─ 元投稿を外部ブラウザで開く
├─ 投稿をブックマークする
└─ ブックマーク済みだけを表示する
Flutterアプリは、SNSのAPIを直接呼び出しません。
Flutterアプリ
↓
Supabaseのtrend_itemsを読み取る
↓
一覧表示・検索・絞り込み
Supabaseへの接続には、Flutterへ配置できるPublishable keyを使用します。
投稿の追加や更新に使用するSecret keyは、Flutterアプリへ入れません。
6.1 Supabaseから投稿を取得する
最初に、FlutterアプリからSupabaseへ接続できるようにします。
第2ページで追加したパッケージを確認します。
cd app
flutter pub add supabase_flutter
flutter pub add flutter_riverpod
flutter pub add url_launcher
flutter pub add shared_preferences
すでに追加済みの場合は、再度実行する必要はありません。
環境変数を定義する
次のファイルを作成します。
app/lib/core/config/app_environment.dart
/// 役割:
/// Flutterのビルド時に渡された公開設定値を保持する。
abstract final class AppEnvironment {
/// SupabaseプロジェクトのURL。
static const String supabaseUrl = String.fromEnvironment(
'SUPABASE_URL',
);
/// Flutterアプリから使用するSupabase Publishable key。
static const String supabasePublishableKey =
String.fromEnvironment(
'SUPABASE_PUBLISHABLE_KEY',
);
/// 役割:
/// 必須の設定値が渡されていることを確認する。
///
/// 入力:
/// なし。
///
/// 出力:
/// 設定が有効な場合は何も返さない。
/// 不足している場合はStateErrorを発生させる。
static void validate() {
if (supabaseUrl.trim().isEmpty) {
throw StateError(
'SUPABASE_URL is not configured.',
);
}
if (supabasePublishableKey.trim().isEmpty) {
throw StateError(
'SUPABASE_PUBLISHABLE_KEY is not configured.',
);
}
}
}
Flutterへ渡すのは、次の2つだけです。
SUPABASE_URL
SUPABASE_PUBLISHABLE_KEY
次の情報はFlutterへ入れてはいけません。
SUPABASE_SECRET_KEY
service_role key
データベースパスワード
YouTube APIキー
外部サービスの秘密トークン
Supabaseを初期化する
次のファイルを置き換えます。
app/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'app/app.dart';
import 'core/config/app_environment.dart';
/// 役割:
/// Flutter、Supabase、Riverpodを初期化してアプリを起動する。
///
/// 入力:
/// なし。
///
/// 出力:
/// なし。
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
AppEnvironment.validate();
await Supabase.initialize(
url: AppEnvironment.supabaseUrl,
anonKey: AppEnvironment.supabasePublishableKey,
);
runApp(
const ProviderScope(
child: SnsTrendApp(),
),
);
}
ビルド時に設定値を渡す
アプリを起動するときは、次のように設定値を渡します。
flutter run \
--dart-define=SUPABASE_URL=SupabaseプロジェクトのURL \
--dart-define=SUPABASE_PUBLISHABLE_KEY=Publishableキー
コマンドへ直接設定値を書きたくない場合は、JSONファイルを使用できます。
次のファイルを作成します。
app/env/development.json
{
"SUPABASE_URL": "SupabaseプロジェクトのURL",
"SUPABASE_PUBLISHABLE_KEY": "Publishableキー"
}
起動時に指定します。
flutter run \
--dart-define-from-file=env/development.json
このファイルをGitへ登録しない場合は、.gitignoreへ追加します。
app/env/*.json
!app/env/example.json
共有用として、値を空にしたファイルだけを登録できます。
app/env/example.json
{
"SUPABASE_URL": "",
"SUPABASE_PUBLISHABLE_KEY": ""
}
Publishable keyは公開環境で使うことを前提としたキーですが、環境ごとの設定を混同しないため、設定ファイルを分けて管理します。
6.2 Repository層を作成する
Flutterの画面からSupabaseを直接呼び出すと、画面表示とデータ取得の責務が混在します。
望ましくない構成
画面
├─ Supabaseへ接続
├─ JSONを変換
├─ 並び替え
├─ エラー処理
└─ Widgetを表示
本教材では、次のように分離します。
画面
↓
状態管理
↓
Repository
↓
Supabase
Repository層は、次の処理を担当します。
- Supabaseから行データを取得する
- JSONを
TrendItemへ変換する - 情報源で絞り込む
- 話題度順または新着順に並べる
- 不正なデータを除外する
- 通信エラーを上位層へ伝える
Flutter側のTrendSourceを作成する
次のファイルを作成します。
app/lib/features/trends/domain/models/trend_source.dart
/// 役割:
/// Flutterアプリで扱う情報源の種類を表す。
enum TrendSource {
mastodonTag('mastodon_tag'),
mastodonStatus('mastodon_status'),
bluesky('bluesky'),
rss('rss'),
youtube('youtube');
const TrendSource(this.databaseValue);
/// Supabaseへ保存されている文字列。
final String databaseValue;
/// 役割:
/// Supabaseの文字列からTrendSourceを取得する。
///
/// 入力:
/// データベースに保存されたsource値。
///
/// 出力:
/// 一致するTrendSource。
/// 一致しない場合はnull。
static TrendSource? tryParse(String value) {
for (final source in TrendSource.values) {
if (source.databaseValue == value) {
return source;
}
}
return null;
}
}
並び替え方法を定義する
次のファイルを作成します。
app/lib/features/trends/domain/models/trend_sort.dart
/// 役割:
/// トレンド一覧の並び替え方法を表す。
enum TrendSort {
trendScore,
newest;
/// Supabaseで最初に並び替えるカラム名。
String get databaseColumn {
return switch (this) {
TrendSort.trendScore => 'trend_score',
TrendSort.newest => 'published_at',
};
}
}
Supabaseへの取得条件を定義する
次のファイルを作成します。
app/lib/features/trends/domain/models/trend_request.dart
import 'trend_sort.dart';
import 'trend_source.dart';
/// 役割:
/// Supabaseへ要求する取得条件を保持する。
final class TrendRequest {
const TrendRequest({
this.source,
this.sort = TrendSort.trendScore,
});
/// nullの場合はすべての情報源を取得する。
final TrendSource? source;
/// 一覧の並び替え方法。
final TrendSort sort;
/// 役割:
/// 情報源を変更した新しい取得条件を作成する。
///
/// 入力:
/// 新しい情報源。nullの場合はすべて。
///
/// 出力:
/// 更新後のTrendRequest。
TrendRequest withSource(TrendSource? value) {
return TrendRequest(
source: value,
sort: sort,
);
}
/// 役割:
/// 並び替え方法を変更した新しい取得条件を作成する。
///
/// 入力:
/// 新しい並び替え方法。
///
/// 出力:
/// 更新後のTrendRequest。
TrendRequest withSort(TrendSort value) {
return TrendRequest(
source: source,
sort: value,
);
}
}
画面内の絞り込み条件を定義する
キーワード検索とブックマーク絞り込みは、Supabaseへ毎回問い合わせず、取得済みデータに対して行います。
次のファイルを作成します。
app/lib/features/trends/domain/models/trend_display_filter.dart
/// 役割:
/// 取得後のトレンド一覧へ適用する画面内フィルターを保持する。
final class TrendDisplayFilter {
const TrendDisplayFilter({
this.keyword = '',
this.onlyBookmarked = false,
});
/// タイトル・本文・投稿者に適用する検索文字列。
final String keyword;
/// trueの場合はブックマーク済みだけを表示する。
final bool onlyBookmarked;
/// 役割:
/// 検索キーワードを変更した新しい状態を作成する。
///
/// 入力:
/// 新しい検索キーワード。
///
/// 出力:
/// 更新後のTrendDisplayFilter。
TrendDisplayFilter withKeyword(String value) {
return TrendDisplayFilter(
keyword: value,
onlyBookmarked: onlyBookmarked,
);
}
/// 役割:
/// ブックマークのみ表示するかを変更する。
///
/// 入力:
/// 新しい表示状態。
///
/// 出力:
/// 更新後のTrendDisplayFilter。
TrendDisplayFilter withOnlyBookmarked(
bool value,
) {
return TrendDisplayFilter(
keyword: keyword,
onlyBookmarked: value,
);
}
}
JSON読み取り用の処理を作成する
次のファイルを作成します。
app/lib/core/json/json_reader.dart
typedef JsonMap = Map<String, Object?>;
/// 役割:
/// 外部データを文字列キーのMapとして検証する。
///
/// 入力:
/// Supabaseなどから受け取った値。
///
/// 出力:
/// 検証済みのJsonMap。
JsonMap requireJsonMap(
Object? value, {
required String context,
}) {
if (value is! Map<Object?, Object?>) {
throw FormatException(
'$context must be a JSON object.',
);
}
final result = <String, Object?>{};
for (final entry in value.entries) {
final key = entry.key;
if (key is! String) {
throw FormatException(
'$context contains a non-string key.',
);
}
result[key] = entry.value;
}
return result;
}
/// 役割:
/// 必須文字列を読み取る。
///
/// 入力:
/// JSON、キー名、確認用の文脈。
///
/// 出力:
/// 空ではない文字列。
String requireString(
JsonMap map,
String key, {
required String context,
}) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {
return value;
}
throw FormatException(
'$context.$key must be a non-empty string.',
);
}
/// 役割:
/// 任意文字列を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// 文字列。取得できない場合はnull。
String? optionalString(
JsonMap map,
String key,
) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {
return value;
}
return null;
}
/// 役割:
/// 任意整数を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// 整数へ変換できる値。取得できない場合はnull。
int? optionalInt(
JsonMap map,
String key,
) {
final value = map[key];
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
/// 役割:
/// 任意の小数を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// doubleへ変換できる値。取得できない場合はnull。
double? optionalDouble(
JsonMap map,
String key,
) {
final value = map[key];
if (value is double) {
return value;
}
if (value is num) {
return value.toDouble();
}
if (value is String) {
return double.tryParse(value);
}
return null;
}
/// 役割:
/// ISO 8601形式の日時を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// UTC日時。取得できない場合はnull。
DateTime? optionalDateTime(
JsonMap map,
String key,
) {
final value = optionalString(
map,
key,
);
if (value == null) {
return null;
}
return DateTime.tryParse(value)?.toUtc();
}
TrendItemを作成する
次のファイルを作成します。
app/lib/features/trends/domain/models/trend_item.dart
import '../../../../core/json/json_reader.dart';
import 'trend_source.dart';
/// 役割:
/// Supabaseから読み取ったトレンド情報を保持する。
final class TrendItem {
const TrendItem({
required this.id,
required this.source,
required this.title,
required this.text,
required this.authorName,
required this.publishedAt,
required this.originalUrl,
required this.thumbnailUrl,
required this.likeCount,
required this.commentCount,
required this.shareCount,
required this.trendScore,
required this.collectedAt,
});
final String id;
final TrendSource source;
final String title;
final String text;
final String? authorName;
final DateTime? publishedAt;
final String originalUrl;
final String? thumbnailUrl;
final int? likeCount;
final int? commentCount;
final int? shareCount;
final double trendScore;
final DateTime collectedAt;
/// キーワード検索に使用する結合済み文字列。
String get searchableText {
return <String>[
title,
text,
authorName ?? '',
].join('\n').toLowerCase();
}
/// 役割:
/// Supabaseの行データからTrendItemを作成する。
///
/// 入力:
/// Supabaseから取得したJSON。
///
/// 出力:
/// 検証済みのTrendItem。
factory TrendItem.fromJson(JsonMap json) {
final sourceValue = requireString(
json,
'source',
context: 'TrendItem',
);
final source = TrendSource.tryParse(
sourceValue,
);
if (source == null) {
throw FormatException(
'Unknown trend source: $sourceValue',
);
}
final collectedAt = optionalDateTime(
json,
'collected_at',
);
if (collectedAt == null) {
throw const FormatException(
'TrendItem.collected_at is invalid.',
);
}
return TrendItem(
id: requireString(
json,
'id',
context: 'TrendItem',
),
source: source,
title: requireString(
json,
'title',
context: 'TrendItem',
),
text: optionalString(
json,
'text',
) ??
'',
authorName: optionalString(
json,
'author_name',
),
publishedAt: optionalDateTime(
json,
'published_at',
),
originalUrl: requireString(
json,
'original_url',
context: 'TrendItem',
),
thumbnailUrl: optionalString(
json,
'thumbnail_url',
),
likeCount: optionalInt(
json,
'like_count',
),
commentCount: optionalInt(
json,
'comment_count',
),
shareCount: optionalInt(
json,
'share_count',
),
trendScore: optionalDouble(
json,
'trend_score',
) ??
0,
collectedAt: collectedAt,
);
}
}
Repositoryのインターフェースを作成する
次のファイルを作成します。
app/lib/features/trends/domain/repositories/trend_repository.dart
import '../models/trend_item.dart';
import '../models/trend_request.dart';
/// 役割:
/// トレンド情報の取得方法を抽象化する。
abstract interface class TrendRepository {
/// 役割:
/// 指定条件に一致するトレンド情報を取得する。
///
/// 入力:
/// 情報源と並び替え条件。
///
/// 出力:
/// TrendItemの一覧。
Future<List<TrendItem>> fetchTrends({
required TrendRequest request,
});
}
画面側は、データがSupabaseから来ていることを意識しません。
将来、REST APIやローカルDBへ変更した場合も、Repositoryの実装だけを交換できます。
Supabase用Repositoryを作成する
次のファイルを作成します。
app/lib/features/trends/data/repositories/supabase_trend_repository.dart
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../../core/json/json_reader.dart';
import '../../domain/models/trend_item.dart';
import '../../domain/models/trend_request.dart';
import '../../domain/models/trend_sort.dart';
import '../../domain/repositories/trend_repository.dart';
/// 役割:
/// Supabaseのtrend_itemsテーブルから情報を取得する。
final class SupabaseTrendRepository
implements TrendRepository {
const SupabaseTrendRepository({
required SupabaseClient client,
}) : _client = client;
final SupabaseClient _client;
static const String _selectedColumns = '''
id,
source,
title,
text,
author_name,
published_at,
original_url,
thumbnail_url,
like_count,
comment_count,
share_count,
trend_score,
collected_at
''';
/// 1回に取得する最大件数。
static const int _fetchLimit = 300;
@override
Future<List<TrendItem>> fetchTrends({
required TrendRequest request,
}) async {
final Object? response = await _fetchRows(
request,
);
if (response is! List<Object?>) {
throw const FormatException(
'Supabase response must be a list.',
);
}
final items = <TrendItem>[];
for (final row in response) {
try {
final json = requireJsonMap(
row,
context: 'trend_items row',
);
items.add(
TrendItem.fromJson(json),
);
} on FormatException catch (error) {
// 1件の不正データによって一覧全体を停止させない。
print(
'Skipped invalid trend item: $error',
);
}
}
_sortItems(
items: items,
sort: request.sort,
);
return List<TrendItem>.unmodifiable(
items,
);
}
/// 役割:
/// 情報源の有無に応じたSupabaseクエリを実行する。
///
/// 入力:
/// 取得条件。
///
/// 出力:
/// Supabaseから返された未検証データ。
Future<Object?> _fetchRows(
TrendRequest request,
) async {
final source = request.source;
if (source == null) {
return _client
.from('trend_items')
.select(_selectedColumns)
.order(
request.sort.databaseColumn,
ascending: false,
)
.limit(_fetchLimit);
}
return _client
.from('trend_items')
.select(_selectedColumns)
.eq(
'source',
source.databaseValue,
)
.order(
request.sort.databaseColumn,
ascending: false,
)
.limit(_fetchLimit);
}
/// 役割:
/// 取得した投稿を指定条件で並べ替える。
///
/// 入力:
/// 投稿一覧と並び替え方法。
///
/// 出力:
/// なし。受け取ったListを並べ替える。
void _sortItems({
required List<TrendItem> items,
required TrendSort sort,
}) {
items.sort((left, right) {
return switch (sort) {
TrendSort.trendScore =>
_compareByTrendScore(
left,
right,
),
TrendSort.newest =>
_compareByPublishedAt(
left,
right,
),
};
});
}
/// 役割:
/// 話題度が高い順に比較する。
///
/// 入力:
/// 比較する2件のTrendItem。
///
/// 出力:
/// List.sortで使用する比較結果。
int _compareByTrendScore(
TrendItem left,
TrendItem right,
) {
final scoreResult =
right.trendScore.compareTo(
left.trendScore,
);
if (scoreResult != 0) {
return scoreResult;
}
return _compareByPublishedAt(
left,
right,
);
}
/// 役割:
/// 公開日時が新しい順に比較する。
///
/// 入力:
/// 比較する2件のTrendItem。
///
/// 出力:
/// List.sortで使用する比較結果。
int _compareByPublishedAt(
TrendItem left,
TrendItem right,
) {
final leftDate = left.publishedAt;
final rightDate = right.publishedAt;
if (leftDate == null && rightDate == null) {
return right.collectedAt.compareTo(
left.collectedAt,
);
}
if (leftDate == null) {
return 1;
}
if (rightDate == null) {
return -1;
}
return rightDate.compareTo(leftDate);
}
}
本教材では、1回の取得を300件に制限しています。
すべての投稿を一度に取得すると、データ数の増加に応じて通信量や描画負荷が増えるためです。
MVP完成後にデータ量が増えた場合は、ページネーションを追加します。
6.3 状態管理を実装する
状態管理にはRiverpodを使用します。
状態を次の4種類に分けます。
取得条件
├─ 情報源
└─ 並び替え方法
画面内フィルター
├─ 検索キーワード
└─ ブックマーク済みのみ
非同期データ
└─ Supabaseから取得したTrendItem
端末内データ
└─ ブックマークID
検索キーワードを入力するたびにSupabaseへ再接続しないよう、取得条件と画面内フィルターを分離します。
取得条件のNotifierを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/providers/trend_request_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/trend_request.dart';
import '../../domain/models/trend_sort.dart';
import '../../domain/models/trend_source.dart';
/// Supabaseへ送る取得条件を提供する。
final trendRequestProvider =
NotifierProvider<
TrendRequestNotifier,
TrendRequest
>(
TrendRequestNotifier.new,
);
/// 役割:
/// 情報源と並び替え方法の変更を管理する。
final class TrendRequestNotifier
extends Notifier<TrendRequest> {
@override
TrendRequest build() {
return const TrendRequest();
}
/// 役割:
/// 表示する情報源を変更する。
///
/// 入力:
/// TrendSource。nullの場合はすべて。
///
/// 出力:
/// なし。
void setSource(TrendSource? source) {
state = state.withSource(source);
}
/// 役割:
/// 一覧の並び替え方法を変更する。
///
/// 入力:
/// TrendSort。
///
/// 出力:
/// なし。
void setSort(TrendSort sort) {
state = state.withSort(sort);
}
}
画面内フィルターのNotifierを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/providers/trend_display_filter_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/trend_display_filter.dart';
/// 画面内で使用する絞り込み条件を提供する。
final trendDisplayFilterProvider =
NotifierProvider<
TrendDisplayFilterNotifier,
TrendDisplayFilter
>(
TrendDisplayFilterNotifier.new,
);
/// 役割:
/// キーワード検索とブックマーク絞り込みを管理する。
final class TrendDisplayFilterNotifier
extends Notifier<TrendDisplayFilter> {
@override
TrendDisplayFilter build() {
return const TrendDisplayFilter();
}
/// 役割:
/// キーワード検索条件を変更する。
///
/// 入力:
/// 入力された文字列。
///
/// 出力:
/// なし。
void setKeyword(String keyword) {
state = state.withKeyword(
keyword.trim(),
);
}
/// 役割:
/// ブックマーク済みだけを表示するか変更する。
///
/// 入力:
/// trueの場合はブックマーク済みだけを表示する。
///
/// 出力:
/// なし。
void setOnlyBookmarked(bool value) {
state = state.withOnlyBookmarked(
value,
);
}
}
Repository Providerを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/providers/trend_repository_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../data/repositories/supabase_trend_repository.dart';
import '../../domain/repositories/trend_repository.dart';
/// 役割:
/// TrendRepositoryの実装をアプリ全体へ提供する。
final trendRepositoryProvider =
Provider<TrendRepository>((ref) {
return SupabaseTrendRepository(
client: Supabase.instance.client,
);
});
非同期一覧を管理する
次のファイルを作成します。
app/lib/features/trends/presentation/providers/trend_list_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/trend_item.dart';
import 'trend_repository_provider.dart';
import 'trend_request_notifier.dart';
/// Supabaseから取得した未加工の一覧を提供する。
final rawTrendItemsProvider =
AsyncNotifierProvider<
TrendListNotifier,
List<TrendItem>
>(
TrendListNotifier.new,
);
/// 役割:
/// Supabaseからのトレンド取得と再読み込みを管理する。
final class TrendListNotifier
extends AsyncNotifier<List<TrendItem>> {
@override
Future<List<TrendItem>> build() async {
final request = ref.watch(
trendRequestProvider,
);
final repository = ref.watch(
trendRepositoryProvider,
);
return repository.fetchTrends(
request: request,
);
}
/// 役割:
/// 現在の取得条件を使ってデータを再取得する。
///
/// 入力:
/// なし。
///
/// 出力:
/// 再取得完了を表すFuture。
Future<void> reload() async {
final request = ref.read(
trendRequestProvider,
);
final repository = ref.read(
trendRepositoryProvider,
);
state = const AsyncLoading<List<TrendItem>>();
state = await AsyncValue.guard(
() {
return repository.fetchTrends(
request: request,
);
},
);
}
}
AsyncValueによって、次の3状態を一つの型で管理できます。
AsyncLoading
└─ 読み込み中
AsyncData
└─ 取得成功
AsyncError
└─ 取得失敗
画面側で、別々のisLoadingやerrorMessageを用意する必要がありません。
6.4 トレンド一覧をカード表示する
一覧カードには、次の情報を表示します。
トレンドカード
├─ 情報源
├─ 話題度
├─ タイトル
├─ 本文の一部
├─ 投稿者
├─ 公開日時
├─ いいね数
├─ コメント数
├─ 共有数
├─ サムネイル
├─ 元投稿を開くボタン
└─ ブックマークボタン
相対時間を整形する
追加パッケージを使わず、簡単な相対時刻を作成します。
次のファイルを作成します。
app/lib/core/formatters/relative_time_formatter.dart
/// 役割:
/// 日時を「3時間前」などの相対表記へ変換する。
///
/// 入力:
/// 表示対象の日時と、比較に使用する現在日時。
///
/// 出力:
/// 日本語の相対時間。日時がない場合は「日時不明」。
String formatRelativeTime(
DateTime? value, {
DateTime? now,
}) {
if (value == null) {
return '日時不明';
}
final currentTime =
(now ?? DateTime.now()).toUtc();
final targetTime = value.toUtc();
final difference = currentTime.difference(
targetTime,
);
if (difference.isNegative) {
return 'たった今';
}
if (difference.inMinutes < 1) {
return 'たった今';
}
if (difference.inHours < 1) {
return '${difference.inMinutes}分前';
}
if (difference.inDays < 1) {
return '${difference.inHours}時間前';
}
if (difference.inDays < 7) {
return '${difference.inDays}日前';
}
final localDate = targetTime.toLocal();
return '${localDate.year}/'
'${localDate.month.toString().padLeft(2, '0')}/'
'${localDate.day.toString().padLeft(2, '0')}';
}
件数を短く表示する
次のファイルを作成します。
app/lib/core/formatters/count_formatter.dart
/// 役割:
/// 大きな件数を1.2万などの短い表記へ変換する。
///
/// 入力:
/// 0以上の件数。
///
/// 出力:
/// 画面表示用の文字列。
String formatCompactCount(int value) {
if (value >= 100000000) {
final number = value / 100000000;
return '${_removeTrailingZero(number)}億';
}
if (value >= 10000) {
final number = value / 10000;
return '${_removeTrailingZero(number)}万';
}
return value.toString();
}
/// 役割:
/// 小数第1位が0の場合に整数表記へ戻す。
///
/// 入力:
/// 表示する数値。
///
/// 出力:
/// 末尾の不要な0を除いた文字列。
String _removeTrailingZero(double value) {
final fixed = value.toStringAsFixed(1);
if (fixed.endsWith('.0')) {
return fixed.substring(
0,
fixed.length - 2,
);
}
return fixed;
}
情報源の表示を定義する
DomainモデルへFlutterのIconDataを持たせず、Presentation層で定義します。
次のファイルを作成します。
app/lib/features/trends/presentation/extensions/trend_source_presentation.dart
import 'package:flutter/material.dart';
import '../../domain/models/trend_source.dart';
/// 役割:
/// TrendSourceへ画面表示用の情報を追加する。
extension TrendSourcePresentation on TrendSource {
/// 画面表示用の名称。
String get label {
return switch (this) {
TrendSource.mastodonTag => 'Mastodonタグ',
TrendSource.mastodonStatus => 'Mastodon',
TrendSource.bluesky => 'Bluesky',
TrendSource.rss => 'RSS',
TrendSource.youtube => 'YouTube',
};
}
/// 画面表示用のアイコン。
IconData get icon {
return switch (this) {
TrendSource.mastodonTag => Icons.tag,
TrendSource.mastodonStatus =>
Icons.forum_outlined,
TrendSource.bluesky => Icons.cloud_outlined,
TrendSource.rss => Icons.rss_feed,
TrendSource.youtube =>
Icons.play_circle_outline,
};
}
}
トレンドカードを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/widgets/trend_card.dart
import 'package:flutter/material.dart';
import '../../../../core/formatters/count_formatter.dart';
import '../../../../core/formatters/relative_time_formatter.dart';
import '../../domain/models/trend_item.dart';
import '../extensions/trend_source_presentation.dart';
/// 役割:
/// 1件のトレンド情報をカードとして表示する。
final class TrendCard extends StatelessWidget {
const TrendCard({
required this.item,
required this.isBookmarked,
required this.onOpen,
required this.onToggleBookmark,
super.key,
});
final TrendItem item;
final bool isBookmarked;
final VoidCallback onOpen;
final VoidCallback onToggleBookmark;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: onOpen,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (item.thumbnailUrl != null)
_TrendThumbnail(
imageUrl: item.thumbnailUrl!,
),
Padding(
padding: const EdgeInsets.fromLTRB(
16,
14,
10,
14,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(
item.source.icon,
size: 18,
),
const SizedBox(width: 6),
Expanded(
child: Text(
item.source.label,
style: theme.textTheme.labelMedium,
),
),
_TrendScoreBadge(
score: item.trendScore,
),
IconButton(
tooltip: isBookmarked
? 'ブックマークを解除'
: 'ブックマークへ追加',
onPressed: onToggleBookmark,
icon: Icon(
isBookmarked
? Icons.bookmark
: Icons.bookmark_border,
),
),
],
),
const SizedBox(height: 8),
Text(
item.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
height: 1.45,
),
),
if (item.text.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
item.text,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(
height: 1.55,
),
),
],
const SizedBox(height: 12),
_TrendMetadata(item: item),
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: _EngagementSummary(
item: item,
),
),
TextButton.icon(
onPressed: onOpen,
icon: const Icon(
Icons.open_in_new,
size: 18,
),
label: const Text(
'元投稿を見る',
),
),
],
),
],
),
),
],
),
),
);
}
}
/// 役割:
/// トレンドのサムネイル画像を表示する。
final class _TrendThumbnail extends StatelessWidget {
const _TrendThumbnail({
required this.imageUrl,
});
final String imageUrl;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
return const SizedBox.shrink();
},
),
);
}
}
/// 役割:
/// 話題度を0点から100点のバッジとして表示する。
final class _TrendScoreBadge extends StatelessWidget {
const _TrendScoreBadge({
required this.score,
});
final double score;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 5,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'話題度 ${score.toStringAsFixed(0)}',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
);
}
}
/// 役割:
/// 投稿者と公開日時を表示する。
final class _TrendMetadata extends StatelessWidget {
const _TrendMetadata({
required this.item,
});
final TrendItem item;
@override
Widget build(BuildContext context) {
final values = <String>[
if (item.authorName != null)
item.authorName!,
formatRelativeTime(
item.publishedAt,
),
];
return Text(
values.join(' ・ '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall,
);
}
}
/// 役割:
/// 取得できた反応数だけを表示する。
final class _EngagementSummary
extends StatelessWidget {
const _EngagementSummary({
required this.item,
});
final TrendItem item;
@override
Widget build(BuildContext context) {
final values = <Widget>[];
if (item.likeCount != null) {
values.add(
_EngagementValue(
icon: Icons.favorite_border,
value: item.likeCount!,
semanticLabel: 'いいね',
),
);
}
if (item.commentCount != null) {
values.add(
_EngagementValue(
icon: Icons.chat_bubble_outline,
value: item.commentCount!,
semanticLabel: 'コメント',
),
);
}
if (item.shareCount != null) {
values.add(
_EngagementValue(
icon: Icons.repeat,
value: item.shareCount!,
semanticLabel: '共有',
),
);
}
if (values.isEmpty) {
return const Text(
'反応数の取得なし',
);
}
return Wrap(
spacing: 12,
runSpacing: 6,
children: values,
);
}
}
/// 役割:
/// 1種類の反応数をアイコン付きで表示する。
final class _EngagementValue
extends StatelessWidget {
const _EngagementValue({
required this.icon,
required this.value,
required this.semanticLabel,
});
final IconData icon;
final int value;
final String semanticLabel;
@override
Widget build(BuildContext context) {
return Semantics(
label:
'$semanticLabel ${value.toString()}件',
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
icon,
size: 16,
),
const SizedBox(width: 4),
Text(
formatCompactCount(value),
style: Theme.of(context)
.textTheme
.bodySmall,
),
],
),
);
}
}
取得できない反応数は、0件として表示しません。
likeCount = 0
└─ ハートアイコンと「0」を表示する
likeCount = null
└─ いいね数自体を表示しない
6.5 SNS別に絞り込む
情報源の絞り込みには、横スクロールできるChoiceChipを使用します。
表示する選択肢は次のとおりです。
すべて
Mastodonタグ
Mastodon
Bluesky
RSS
YouTube
情報源フィルターを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/widgets/trend_source_filter.dart
import 'package:flutter/material.dart';
import '../../domain/models/trend_source.dart';
import '../extensions/trend_source_presentation.dart';
/// 役割:
/// 情報源をChoiceChipで選択する。
final class TrendSourceFilter extends StatelessWidget {
const TrendSourceFilter({
required this.selectedSource,
required this.onChanged,
super.key,
});
final TrendSource? selectedSource;
final ValueChanged<TrendSource?> onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
children: <Widget>[
ChoiceChip(
label: const Text('すべて'),
selected: selectedSource == null,
onSelected: (bool selected) {
if (selected) {
onChanged(null);
}
},
),
const SizedBox(width: 8),
for (final source in TrendSource.values) ...[
ChoiceChip(
avatar: Icon(
source.icon,
size: 17,
),
label: Text(source.label),
selected: selectedSource == source,
onSelected: (bool selected) {
if (selected) {
onChanged(source);
}
},
),
const SizedBox(width: 8),
],
],
),
);
}
}
情報源を変更すると、trendRequestProviderが更新されます。
rawTrendItemsProviderは取得条件を監視しているため、自動的にSupabaseへ再接続します。
Mastodonを選択
↓
TrendRequest.sourceを変更
↓
TrendListNotifierを再構築
↓
source = mastodon_statusで取得
↓
画面を更新
6.6 キーワードで検索する
キーワード検索は、取得済みのデータに対して行います。
検索対象は次の3項目です。
タイトル
本文・概要
投稿者・配信元
英字については、大文字と小文字を区別しません。
Flutter
flutter
FLUTTER
すべて同じ検索条件として扱う
表示用一覧Providerを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/providers/visible_trend_items_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/models/trend_item.dart';
import '../../../bookmarks/presentation/providers/bookmark_ids_notifier.dart';
import 'trend_display_filter_notifier.dart';
import 'trend_list_notifier.dart';
/// 役割:
/// 取得済み一覧へキーワードとブックマーク条件を適用する。
final visibleTrendItemsProvider =
Provider<AsyncValue<List<TrendItem>>>((ref) {
final itemsState = ref.watch(
rawTrendItemsProvider,
);
final filter = ref.watch(
trendDisplayFilterProvider,
);
final bookmarkState = ref.watch(
bookmarkIdsProvider,
);
final bookmarkedIds =
bookmarkState.asData?.value ??
const <String>{};
return itemsState.whenData((items) {
final normalizedKeyword =
filter.keyword.toLowerCase();
final filteredItems = items.where((item) {
if (normalizedKeyword.isNotEmpty &&
!item.searchableText.contains(
normalizedKeyword,
)) {
return false;
}
if (filter.onlyBookmarked &&
!bookmarkedIds.contains(item.id)) {
return false;
}
return true;
}).toList(growable: false);
return filteredItems;
});
});
検索キーワードを変更しても、Supabaseへの再問い合わせは発生しません。
検索文字を入力
↓
TrendDisplayFilterだけを変更
↓
取得済みListを絞り込む
↓
画面を更新
検索フィールドを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/widgets/trend_search_field.dart
import 'package:flutter/material.dart';
/// 役割:
/// トレンド一覧の検索キーワードを入力する。
final class TrendSearchField extends StatelessWidget {
const TrendSearchField({
required this.controller,
required this.onChanged,
required this.onClear,
super.key,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: onChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'キーワードで検索',
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
tooltip: '検索をクリア',
onPressed: onClear,
icon: const Icon(Icons.clear),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
);
}
}
TextFieldの入力中にクリアボタンを更新するには、親WidgetをStatefulWidgetにするか、ValueListenableBuilderを使います。
後ほど作成するTrendPageでは、ConsumerStatefulWidgetを使用します。
6.7 話題度順・新着順を切り替える
一覧では、次の2つの並び替え方法を用意します。
話題度順
└─ trend_scoreが高い順
新着順
└─ published_atが新しい順
並び替えボタンを作成する
次のファイルを作成します。
app/lib/features/trends/presentation/widgets/trend_sort_selector.dart
import 'package:flutter/material.dart';
import '../../domain/models/trend_sort.dart';
/// 役割:
/// 話題度順と新着順を切り替える。
final class TrendSortSelector extends StatelessWidget {
const TrendSortSelector({
required this.selectedSort,
required this.onChanged,
super.key,
});
final TrendSort selectedSort;
final ValueChanged<TrendSort> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<TrendSort>(
segments:
const <ButtonSegment<TrendSort>>[
ButtonSegment<TrendSort>(
value: TrendSort.trendScore,
icon: Icon(Icons.trending_up),
label: Text('話題度順'),
),
ButtonSegment<TrendSort>(
value: TrendSort.newest,
icon: Icon(Icons.schedule),
label: Text('新着順'),
),
],
selected: <TrendSort>{
selectedSort,
},
onSelectionChanged:
(Set<TrendSort> selections) {
if (selections.isEmpty) {
return;
}
onChanged(selections.first);
},
);
}
}
並び替え方法を変更すると、Supabaseから選択した順番で再取得します。
Repository側でも再度並び替えるため、公開日時がnullのデータは末尾へ移動します。
6.8 元投稿を外部ブラウザで開く
投稿カードをタップしたときは、Mastodon、Bluesky、RSS記事、YouTubeなどの元ページを開きます。
アプリ内に投稿全文を複製するのではなく、情報元へ移動できるようにします。
外部リンクサービスを作成する
次のファイルを作成します。
app/lib/core/services/external_link_service.dart
import 'package:url_launcher/url_launcher.dart';
/// 役割:
/// HTTPまたはHTTPSのURLを外部ブラウザで開く。
final class ExternalLinkService {
const ExternalLinkService();
/// 役割:
/// 文字列URLを検証し、外部アプリで開く。
///
/// 入力:
/// 元投稿または元記事のURL。
///
/// 出力:
/// 正常に開けた場合は何も返さない。
/// 開けない場合は例外を発生させる。
Future<void> open(String value) async {
final uri = Uri.tryParse(
value.trim(),
);
if (uri == null ||
!uri.hasScheme ||
(uri.scheme != 'https' &&
uri.scheme != 'http')) {
throw const FormatException(
'URLが正しくありません。',
);
}
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
throw StateError(
'外部ブラウザを開けませんでした。',
);
}
}
}
Providerを作成する
次のファイルを作成します。
app/lib/core/providers/service_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/external_link_service.dart';
/// 役割:
/// 外部リンクを開くサービスを提供する。
final externalLinkServiceProvider =
Provider<ExternalLinkService>((ref) {
return const ExternalLinkService();
});
URLを開けなかった場合は、画面上にエラーを表示します。
例外を無視すると、ボタンを押しても何も起きないように見えるためです。
6.9 ブックマークを端末内に保存する
ブックマークは、Supabaseではなく利用者の端末内へ保存します。
ユーザー登録を必要とせず、無料で実装するためです。
保存するのは、投稿全体ではなくTrendItem.idだけです。
ブックマークデータ
[
"bluesky:at://did:plc:example/app.bsky.feed.post/abc",
"youtube:video-id",
"rss:hash-value"
]
投稿内容はSupabaseから取得し、端末にはIDだけを保持します。
Bookmark Repositoryを定義する
次のファイルを作成します。
app/lib/features/bookmarks/domain/repositories/bookmark_repository.dart
/// 役割:
/// ブックマークIDの保存方法を抽象化する。
abstract interface class BookmarkRepository {
/// 役割:
/// 保存済みのTrendItem IDを取得する。
///
/// 入力:
/// なし。
///
/// 出力:
/// 重複のないID一覧。
Future<Set<String>> loadIds();
/// 役割:
/// ブックマークIDを端末へ保存する。
///
/// 入力:
/// 保存するID一覧。
///
/// 出力:
/// 保存完了を表すFuture。
Future<void> saveIds(Set<String> ids);
}
SharedPreferencesAsyncで保存する
次のファイルを作成します。
app/lib/features/bookmarks/data/repositories/local_bookmark_repository.dart
import 'package:shared_preferences/shared_preferences.dart';
import '../../domain/repositories/bookmark_repository.dart';
/// 役割:
/// SharedPreferencesを使ってブックマークIDを端末へ保存する。
final class LocalBookmarkRepository
implements BookmarkRepository {
LocalBookmarkRepository({
SharedPreferencesAsync? preferences,
}) : _preferences =
preferences ??
SharedPreferencesAsync();
static const String _storageKey =
'trend_bookmark_ids';
final SharedPreferencesAsync _preferences;
@override
Future<Set<String>> loadIds() async {
final storedIds =
await _preferences.getStringList(
_storageKey,
);
if (storedIds == null) {
return const <String>{};
}
return Set<String>.unmodifiable(
storedIds.where(
(id) => id.trim().isNotEmpty,
),
);
}
@override
Future<void> saveIds(Set<String> ids) async {
final sortedIds = ids.toList()
..sort();
await _preferences.setStringList(
_storageKey,
sortedIds,
);
}
}
ブックマーク状態を管理する
次のファイルを作成します。
app/lib/features/bookmarks/presentation/providers/bookmark_ids_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/local_bookmark_repository.dart';
import '../../domain/repositories/bookmark_repository.dart';
/// 役割:
/// ブックマークRepositoryを提供する。
final bookmarkRepositoryProvider =
Provider<BookmarkRepository>((ref) {
return LocalBookmarkRepository();
});
/// 保存済みのTrendItem IDを提供する。
final bookmarkIdsProvider =
AsyncNotifierProvider<
BookmarkIdsNotifier,
Set<String>
>(
BookmarkIdsNotifier.new,
);
/// 役割:
/// ブックマークの読み込み、追加、解除を管理する。
final class BookmarkIdsNotifier
extends AsyncNotifier<Set<String>> {
@override
Future<Set<String>> build() async {
final repository = ref.watch(
bookmarkRepositoryProvider,
);
return repository.loadIds();
}
/// 役割:
/// 指定IDのブックマーク状態を反転する。
///
/// 入力:
/// TrendItemのID。
///
/// 出力:
/// 保存完了を表すFuture。
Future<void> toggle(String id) async {
final normalizedId = id.trim();
if (normalizedId.isEmpty) {
throw const FormatException(
'ブックマークIDが空です。',
);
}
final previousIds =
state.asData?.value ??
const <String>{};
final nextIds = <String>{
...previousIds,
};
if (!nextIds.add(normalizedId)) {
nextIds.remove(normalizedId);
}
final immutableNextIds =
Set<String>.unmodifiable(
nextIds,
);
state = AsyncData<Set<String>>(
immutableNextIds,
);
try {
final repository = ref.read(
bookmarkRepositoryProvider,
);
await repository.saveIds(
immutableNextIds,
);
} on Object catch (error, stackTrace) {
state = AsyncData<Set<String>>(
previousIds,
);
Error.throwWithStackTrace(
error,
stackTrace,
);
}
}
}
ブックマークボタンを押した直後にUIを更新し、その後で端末へ保存しています。
保存に失敗した場合は、変更前の状態へ戻します。
ブックマークを押す
↓
画面上ではすぐに追加
↓
端末へ保存
↓
保存成功
└─ そのまま維持
保存失敗
└─ 変更前へ戻す
ブックマークの制限
SharedPreferencesは、少量の設定値やID一覧を保存する用途に向いています。
次のデータを大量に保存する用途には使用しません。
- 投稿本文全体
- 画像ファイル
- 数千件以上の詳細データ
- 複雑な検索履歴
- オフライン閲覧用の記事全文
大規模なローカル保存が必要になった場合は、SQLiteなどのローカルデータベースを検討します。
トレンド一覧画面を完成させる
ここまで作成したProviderとWidgetを一つの画面へまとめます。
次のファイルを作成します。
app/lib/features/trends/presentation/pages/trend_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/providers/service_providers.dart';
import '../../../bookmarks/presentation/providers/bookmark_ids_notifier.dart';
import '../../domain/models/trend_sort.dart';
import '../../domain/models/trend_source.dart';
import '../providers/trend_display_filter_notifier.dart';
import '../providers/trend_list_notifier.dart';
import '../providers/trend_request_notifier.dart';
import '../providers/visible_trend_items_provider.dart';
import '../widgets/trend_card.dart';
import '../widgets/trend_search_field.dart';
import '../widgets/trend_sort_selector.dart';
import '../widgets/trend_source_filter.dart';
/// 役割:
/// トレンド一覧、検索、絞り込み、ブックマークを表示する。
final class TrendPage
extends ConsumerStatefulWidget {
const TrendPage({super.key});
@override
ConsumerState<TrendPage> createState() {
return _TrendPageState();
}
}
final class _TrendPageState
extends ConsumerState<TrendPage> {
final TextEditingController
_searchController =
TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
/// 役割:
/// 元投稿を外部ブラウザで開く。
///
/// 入力:
/// 開くURL。
///
/// 出力:
/// 開く処理の完了を表すFuture。
Future<void> _openOriginalUrl(
String url,
) async {
try {
final service = ref.read(
externalLinkServiceProvider,
);
await service.open(url);
} on Object catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'元ページを開けませんでした。$error',
),
),
);
}
}
/// 役割:
/// ブックマーク状態を切り替える。
///
/// 入力:
/// TrendItemのID。
///
/// 出力:
/// 保存完了を表すFuture。
Future<void> _toggleBookmark(
String id,
) async {
try {
await ref
.read(bookmarkIdsProvider.notifier)
.toggle(id);
} on Object catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'ブックマークを保存できませんでした。$error',
),
),
);
}
}
/// 役割:
/// 検索文字をクリアして一覧を再表示する。
///
/// 入力:
/// なし。
///
/// 出力:
/// なし。
void _clearSearch() {
_searchController.clear();
ref
.read(
trendDisplayFilterProvider.notifier,
)
.setKeyword('');
setState(() {});
}
@override
Widget build(BuildContext context) {
final request = ref.watch(
trendRequestProvider,
);
final displayFilter = ref.watch(
trendDisplayFilterProvider,
);
final visibleItems = ref.watch(
visibleTrendItemsProvider,
);
final bookmarkIds =
ref
.watch(bookmarkIdsProvider)
.asData
?.value ??
const <String>{};
return Scaffold(
appBar: AppBar(
title: const Text(
'SNSトレンド',
),
actions: <Widget>[
IconButton(
tooltip: displayFilter.onlyBookmarked
? 'すべて表示'
: 'ブックマーク済みを表示',
onPressed: () {
ref
.read(
trendDisplayFilterProvider
.notifier,
)
.setOnlyBookmarked(
!displayFilter.onlyBookmarked,
);
},
icon: Icon(
displayFilter.onlyBookmarked
? Icons.bookmarks
: Icons.bookmarks_outlined,
),
),
],
),
body: SafeArea(
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(
16,
12,
16,
10,
),
child: TrendSearchField(
controller: _searchController,
onChanged: (String value) {
ref
.read(
trendDisplayFilterProvider
.notifier,
)
.setKeyword(value);
setState(() {});
},
onClear: _clearSearch,
),
),
TrendSourceFilter(
selectedSource: request.source,
onChanged:
(TrendSource? source) {
ref
.read(
trendRequestProvider
.notifier,
)
.setSource(source);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
16,
10,
16,
12,
),
child: Row(
children: <Widget>[
Expanded(
child: TrendSortSelector(
selectedSort: request.sort,
onChanged:
(TrendSort sort) {
ref
.read(
trendRequestProvider
.notifier,
)
.setSort(sort);
},
),
),
],
),
),
Expanded(
child: visibleItems.when(
loading: () {
return const Center(
child:
CircularProgressIndicator(),
);
},
error: (
Object error,
StackTrace stackTrace,
) {
return _TrendErrorView(
error: error,
onRetry: () {
ref
.read(
rawTrendItemsProvider
.notifier,
)
.reload();
},
);
},
data: (items) {
if (items.isEmpty) {
return _TrendEmptyView(
isFiltered:
displayFilter.keyword
.isNotEmpty ||
displayFilter
.onlyBookmarked ||
request.source != null,
);
}
return RefreshIndicator(
onRefresh: () {
return ref
.read(
rawTrendItemsProvider
.notifier,
)
.reload();
},
child: ListView.separated(
padding:
const EdgeInsets.fromLTRB(
16,
4,
16,
32,
),
physics:
const AlwaysScrollableScrollPhysics(),
itemCount: items.length,
separatorBuilder: (
BuildContext context,
int index,
) {
return const SizedBox(
height: 12,
);
},
itemBuilder: (
BuildContext context,
int index,
) {
final item = items[index];
return TrendCard(
item: item,
isBookmarked:
bookmarkIds.contains(
item.id,
),
onOpen: () {
_openOriginalUrl(
item.originalUrl,
);
},
onToggleBookmark: () {
_toggleBookmark(
item.id,
);
},
);
},
),
);
},
),
),
],
),
),
);
}
}
/// 役割:
/// データ取得失敗時の表示を作成する。
final class _TrendErrorView
extends StatelessWidget {
const _TrendErrorView({
required this.error,
required this.onRetry,
});
final Object error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(
Icons.cloud_off_outlined,
size: 48,
),
const SizedBox(height: 16),
Text(
'トレンド情報を取得できませんでした。',
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleMedium,
),
const SizedBox(height: 8),
Text(
error.toString(),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodySmall,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('再読み込み'),
),
],
),
),
);
}
}
/// 役割:
/// 表示対象が0件の場合の案内を作成する。
final class _TrendEmptyView
extends StatelessWidget {
const _TrendEmptyView({
required this.isFiltered,
});
final bool isFiltered;
@override
Widget build(BuildContext context) {
return ListView(
physics:
const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(32),
children: <Widget>[
const SizedBox(height: 80),
Icon(
isFiltered
? Icons.search_off
: Icons.inbox_outlined,
size: 52,
),
const SizedBox(height: 16),
Text(
isFiltered
? '条件に一致する情報がありません。'
: 'トレンド情報がまだありません。',
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleMedium,
),
const SizedBox(height: 8),
Text(
isFiltered
? '検索条件や情報源を変更してください。'
: '収集処理が完了すると、ここに表示されます。',
textAlign: TextAlign.center,
),
],
);
}
}
アプリ本体を作成する
次のファイルを作成します。
app/lib/app/app.dart
import 'package:flutter/material.dart';
import '../features/trends/presentation/pages/trend_page.dart';
/// 役割:
/// アプリ全体のテーマと最初の画面を定義する。
final class SnsTrendApp extends StatelessWidget {
const SnsTrendApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SNSトレンド',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(
0xFF315F9B,
),
scaffoldBackgroundColor:
const Color(0xFFF7F8FA),
cardTheme: const CardThemeData(
elevation: 0,
margin: EdgeInsets.zero,
),
),
home: const TrendPage(),
);
}
}
実装後のディレクトリ構成
第6ページまで実装すると、Flutter側は次のようになります。
app/lib/
├─ app/
│ └─ app.dart
│
├─ core/
│ ├─ config/
│ │ └─ app_environment.dart
│ ├─ formatters/
│ │ ├─ count_formatter.dart
│ │ └─ relative_time_formatter.dart
│ ├─ json/
│ │ └─ json_reader.dart
│ ├─ providers/
│ │ └─ service_providers.dart
│ └─ services/
│ └─ external_link_service.dart
│
├─ features/
│ ├─ bookmarks/
│ │ ├─ data/
│ │ │ └─ repositories/
│ │ │ └─ local_bookmark_repository.dart
│ │ ├─ domain/
│ │ │ └─ repositories/
│ │ │ └─ bookmark_repository.dart
│ │ └─ presentation/
│ │ └─ providers/
│ │ └─ bookmark_ids_notifier.dart
│ │
│ └─ trends/
│ ├─ data/
│ │ └─ repositories/
│ │ └─ supabase_trend_repository.dart
│ ├─ domain/
│ │ ├─ models/
│ │ │ ├─ trend_display_filter.dart
│ │ │ ├─ trend_item.dart
│ │ │ ├─ trend_request.dart
│ │ │ ├─ trend_sort.dart
│ │ │ └─ trend_source.dart
│ │ └─ repositories/
│ │ └─ trend_repository.dart
│ └─ presentation/
│ ├─ extensions/
│ │ └─ trend_source_presentation.dart
│ ├─ pages/
│ │ └─ trend_page.dart
│ ├─ providers/
│ │ ├─ trend_display_filter_notifier.dart
│ │ ├─ trend_list_notifier.dart
│ │ ├─ trend_repository_provider.dart
│ │ ├─ trend_request_notifier.dart
│ │ └─ visible_trend_items_provider.dart
│ └─ widgets/
│ ├─ trend_card.dart
│ ├─ trend_search_field.dart
│ ├─ trend_sort_selector.dart
│ └─ trend_source_filter.dart
│
└─ main.dart
アプリを起動する
最初にコードを整形します。
cd app
dart format .
静的解析を実行します。
flutter analyze
問題がなければ起動します。
flutter run \
--dart-define-from-file=env/development.json
確認する内容
アプリ起動後、次の動作を確認します。
初期表示
└─ 話題度が高い順に表示される
情報源
└─ 選択したSNSだけが表示される
検索
└─ タイトル・本文・投稿者から絞り込める
並び替え
├─ 話題度順
└─ 新着順
元投稿
└─ 外部ブラウザで開く
ブックマーク
├─ アイコンが切り替わる
├─ アプリを再起動しても残る
└─ 保存済みだけを表示できる
更新
└─ 下方向へ引っ張ると再取得される
データが表示されない場合
次の順番で確認します。
1. Supabaseのtrend_itemsにデータがあるか
2. trend_itemsのRLSが有効か
3. anonへSELECTポリシーがあるか
4. SUPABASE_URLが正しいか
5. SUPABASE_PUBLISHABLE_KEYが正しいか
6. sourceの文字列がenumと一致しているか
7. collected_atが正しい日時形式か
8. FlutterのログにFormatExceptionが出ていないか
RLSポリシーを確認する
第4ページで、次の読み取りポリシーを作成しました。
create policy "Public can read trend items"
on public.trend_items
for select
to anon, authenticated
using (true);
このポリシーがない場合、Publishable keyからデータを読み取れません。
RLSを無効にするのではなく、必要な読み取り権限だけを設定します。
取得件数が増えた場合の注意
本教材では、最大300件を取得して画面内で検索します。
MVPとしては単純で扱いやすい構成ですが、データが増えた場合は次の問題が発生します。
- 過去の投稿が取得範囲から外れる
- 選択したキーワードの投稿が300件より後ろにある
- 初回表示の通信量が増える
- 端末での検索処理が重くなる
- 画像読み込みが増える
本番運用では、次の改善を検討します。
ページネーション
├─ 20件ずつ取得
└─ 末尾で次のページを読む
データベース検索
├─ title
├─ text
└─ author_name
表示期間の制限
└─ 直近30日だけを取得
サムネイル制御
└─ 画面に近い画像だけを読み込む
7ページで完結するMVPでは、まず300件以内で正常に動作することを確認します。
まとめ
このページでは、Supabaseへ保存したトレンド情報をFlutterアプリから読み込み、利用者が確認できる一覧画面を作成しました。
Flutter側の処理は、次のように分離されています。
Supabase
↓
SupabaseTrendRepository
├─ データを取得する
├─ JSONを検証する
└─ TrendItemへ変換する
↓
TrendListNotifier
├─ 読み込み中
├─ 取得成功
└─ 取得失敗
↓
visibleTrendItemsProvider
├─ キーワード検索
└─ ブックマーク絞り込み
↓
TrendPage
├─ 一覧表示
├─ 情報源フィルター
├─ 並び替え
├─ 外部リンク
└─ ブックマーク
取得条件と画面内の検索条件も分離しました。
Supabaseへ再問い合わせする
├─ 情報源の変更
└─ 並び替えの変更
取得済みデータだけを絞り込む
├─ キーワード入力
└─ ブックマーク済み表示
これにより、検索文字を入力するたびにSupabaseへ接続することを防いでいます。
ブックマークには、投稿全体ではなくIDだけを保存しました。
端末内
└─ TrendItem.id
Supabase
└─ 最新の投稿内容
これで、情報収集、話題度計算、Supabase保存、Flutter表示までがつながりました。
次のページでは、GitHub Actionsの自動収集、API使用量、無料枠、古いデータの削除、投稿削除への対応、Android・iOSでの動作確認を行い、アプリを完成させます。