misskey_api_core 0.0.4-beta
misskey_api_core: ^0.0.4-beta copied to clipboard
A library to make it easy to use the Misskey API in your Flutter and Dart apps.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'package:misskey_api_core/misskey_api_core.dart';
import 'package:misskey_auth/misskey_auth.dart';
void main() {
runApp(const ProviderScope(child: ExampleApp()));
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
final scheme = ColorScheme.fromSeed(seedColor: Colors.indigo);
return GlobalLoaderOverlay(
child: MaterialApp(
title: 'Misskey Core Example',
theme: ThemeData(
colorScheme: scheme,
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFFF7F7FA),
appBarTheme: AppBarTheme(backgroundColor: scheme.surface, foregroundColor: scheme.onSurface),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: scheme.surface,
selectedItemColor: scheme.primary,
unselectedItemColor: scheme.onSurfaceVariant,
),
inputDecorationTheme: InputDecorationTheme(
border: const OutlineInputBorder(),
filled: true,
fillColor: scheme.surface,
),
),
home: const AuthGate(),
),
);
}
}
final instanceUrlProvider = StateProvider<String>((ref) => 'https://misskey.io');
final authStateProvider = StateNotifierProvider<AuthStateNotifier, AuthState>((ref) {
return AuthStateNotifier(ref);
});
enum LogLevel { debug, info, warn, error }
class LogEntry {
final DateTime time;
final LogLevel level;
final String message;
const LogEntry({required this.time, required this.level, required this.message});
}
class LogStore extends StateNotifier<List<LogEntry>> {
static const int maxEntries = 500;
LogStore() : super(const []);
void add(LogEntry entry) {
final list = List<LogEntry>.from(state)..add(entry);
if (list.length > maxEntries) {
list.removeRange(0, list.length - maxEntries);
}
state = list;
}
void clear() => state = const [];
}
final logStoreProvider = StateNotifierProvider<LogStore, List<LogEntry>>((ref) => LogStore());
class InAppLogger implements Logger {
final Ref ref;
InAppLogger(this.ref);
String _mask(String input) {
return input.replaceAll(RegExp(r'\"i\"\s*:\s*\"[^\"]*\"'), '"i":"***"');
}
void _push(LogLevel level, String message) {
ref.read(logStoreProvider.notifier).add(LogEntry(time: DateTime.now(), level: level, message: _mask(message)));
}
@override
void debug(String message) => _push(LogLevel.debug, message);
@override
void info(String message) => _push(LogLevel.info, message);
@override
void warn(String message) => _push(LogLevel.warn, message);
@override
void error(String message, [Object? error, StackTrace? stackTrace]) {
final combined = error == null ? message : '$message error=$error';
_push(LogLevel.error, combined);
}
}
final inAppLoggerProvider = Provider<Logger>((ref) => InAppLogger(ref));
class AuthState {
final bool authenticated;
final String? accessToken;
final Uri? baseUrl;
final String? selfUserId;
const AuthState({required this.authenticated, this.accessToken, this.baseUrl, this.selfUserId});
}
class AuthStateNotifier extends StateNotifier<AuthState> {
final Ref ref;
AuthStateNotifier(this.ref) : super(const AuthState(authenticated: false));
Future<void> authenticate(BuildContext context) async {
final instance = Uri.parse(ref.read(instanceUrlProvider));
final overlay = context.loaderOverlay;
overlay.show();
try {
// misskey_auth 側の具体APIはダミー呼び出し(利用者が差し替え)
final client = MisskeyOAuthClient();
final config = MisskeyOAuthConfig(
host: instance.host,
clientId: 'https://librarylibrarian.github.io/misskey_api_core/',
redirectUri: 'https://librarylibrarian.github.io/misskey_api_core/redirect.html',
scope: 'read:account read:notes write:notes read:following',
callbackScheme: 'misskeyapicore',
);
final token = await client.authenticate(config);
if (token == null) {
// キャンセルや失敗時は非認証のまま
return;
}
// 自分のユーザーIDを取得
final tempApi = MisskeyHttpClient(
config: MisskeyApiConfig(baseUrl: instance, enableLog: true),
tokenProvider: () async => token.accessToken,
);
String? selfId;
try {
final me = await tempApi.send<Map<String, dynamic>>(
'/i',
body: const {},
options: const RequestOptions(idempotent: true),
);
selfId = me['id'] as String?;
} catch (_) {
selfId = null;
}
state = AuthState(authenticated: true, accessToken: token.accessToken, baseUrl: instance, selfUserId: selfId);
} finally {
overlay.hide();
}
}
}
final httpClientProvider = Provider<MisskeyHttpClient?>((ref) {
final s = ref.watch(authStateProvider);
if (!s.authenticated || s.baseUrl == null) return null;
return MisskeyHttpClient(
config: MisskeyApiConfig(baseUrl: s.baseUrl!, enableLog: true),
tokenProvider: () async => s.accessToken,
logger: ref.watch(inAppLoggerProvider),
);
});
class AuthGate extends ConsumerWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authStateProvider);
if (!auth.authenticated) {
return const AuthScreen();
}
return const HomeScreen();
}
}
class AuthScreen extends ConsumerWidget {
const AuthScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final instanceUrl = ref.watch(instanceUrlProvider);
return Scaffold(
appBar: AppBar(title: const Text('Sign in to Misskey')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
decoration: const InputDecoration(labelText: 'Instance URL'),
controller: TextEditingController(text: instanceUrl),
onSubmitted: (v) => ref.read(instanceUrlProvider.notifier).state = v,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(authStateProvider.notifier).authenticate(context),
child: const Text('Authenticate'),
),
],
),
),
);
}
}
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
int _index = 0;
@override
Widget build(BuildContext context) {
final pages = [
const PostNotePage(),
const TimelinePage(),
const FollowingPage(),
const FollowersPage(),
const LogsPage(),
];
return Scaffold(
appBar: AppBar(title: const Text('Misskey API Core Example')),
body: pages[_index],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (i) {
// タブ切替時にフォーカスを明示的に外し、キーボードイベントの不整合を回避
FocusManager.instance.primaryFocus?.unfocus();
setState(() => _index = i);
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.edit), label: 'Post'),
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Timeline'),
BottomNavigationBarItem(icon: Icon(Icons.person_add), label: 'Following'),
BottomNavigationBarItem(icon: Icon(Icons.group), label: 'Followers'),
BottomNavigationBarItem(icon: Icon(Icons.article), label: 'Logs'),
],
),
);
}
}
class PostNotePage extends ConsumerWidget {
const PostNotePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(httpClientProvider);
final controller = TextEditingController();
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Text'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: client == null
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
try {
await client.send<Map<String, dynamic>>(
'/notes/create',
body: {'text': controller.text},
options: const RequestOptions(idempotent: false),
);
if (context.mounted) {
messenger.showSnackBar(const SnackBar(content: Text('Posted!')));
controller.clear();
}
} on MisskeyApiException catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Error: ${e.message}')));
}
},
child: const Text('Post'),
),
],
),
);
}
}
class TimelinePage extends ConsumerStatefulWidget {
const TimelinePage({super.key});
@override
ConsumerState<TimelinePage> createState() => _TimelinePageState();
}
class _TimelinePageState extends ConsumerState<TimelinePage> {
List<dynamic> notes = const [];
BuildContext? _overlayCtx;
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _overlayCtx != null) _load(_overlayCtx);
});
}
Future<void> _load([BuildContext? overlayCtx]) async {
final client = ref.read(httpClientProvider);
if (client == null) return;
final messenger = ScaffoldMessenger.of(context);
final ctx = overlayCtx ?? _overlayCtx;
if (ctx == null) return;
final overlay = ctx.loaderOverlay;
overlay.show();
try {
final res = await client.send<List<dynamic>>(
'/notes/timeline',
body: const {'limit': 20},
options: const RequestOptions(idempotent: true),
);
if (!mounted) return;
setState(() => notes = res);
} on MisskeyApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Timeline error: ${e.message}')));
} finally {
if (mounted && _overlayCtx == ctx) overlay.hide();
}
}
@override
Widget build(BuildContext context) {
return LoaderOverlay(
child: Builder(
builder: (c) {
_overlayCtx = c;
return RefreshIndicator(
onRefresh: () => _load(c),
child: ListView.builder(
itemCount: notes.length,
itemBuilder: (c, i) => ListTile(
title: Text(notes[i]['text']?.toString() ?? ''),
subtitle: Text(notes[i]['user']?['username']?.toString() ?? ''),
),
),
);
},
),
);
}
}
class FollowingPage extends ConsumerStatefulWidget {
const FollowingPage({super.key});
@override
ConsumerState<FollowingPage> createState() => _FollowingPageState();
}
class _FollowingPageState extends ConsumerState<FollowingPage> {
List<dynamic> users = const [];
BuildContext? _overlayCtx;
Map<String, dynamic>? _pickUser(dynamic item) {
if (item is Map<String, dynamic>) {
if (item['username'] is String) return item;
if (item['followee'] is Map) {
return (item['followee'] as Map).cast<String, dynamic>();
}
if (item['user'] is Map) {
return (item['user'] as Map).cast<String, dynamic>();
}
if (item['follower'] is Map) {
return (item['follower'] as Map).cast<String, dynamic>();
}
}
return null;
}
Future<void> _load([BuildContext? overlayCtx]) async {
final client = ref.read(httpClientProvider);
if (client == null) return;
final selfId = ref.read(authStateProvider).selfUserId;
if (selfId == null) return;
final messenger = ScaffoldMessenger.of(context);
final ctx = overlayCtx ?? _overlayCtx;
if (ctx == null) return;
final overlay = ctx.loaderOverlay;
overlay.show();
try {
final res = await client.send<List<dynamic>>(
'/users/following',
body: {'userId': selfId, 'limit': 30, 'detailed': true},
options: const RequestOptions(idempotent: true),
);
if (!mounted) return;
setState(() => users = res);
} on MisskeyApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Following error: ${e.message}')));
} finally {
if (mounted && _overlayCtx == ctx) overlay.hide();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _overlayCtx != null) _load(_overlayCtx);
});
}
@override
Widget build(BuildContext context) {
return LoaderOverlay(
child: Builder(
builder: (c) {
_overlayCtx = c;
return RefreshIndicator(
onRefresh: () => _load(c),
child: ListView.builder(
itemCount: users.length,
itemBuilder: (c, i) => ListTile(
title: Text(_pickUser(users[i])?['username']?.toString() ?? ''),
subtitle: Text(_pickUser(users[i])?['host']?.toString() ?? ''),
),
),
);
},
),
);
}
}
class FollowersPage extends ConsumerStatefulWidget {
const FollowersPage({super.key});
@override
ConsumerState<FollowersPage> createState() => _FollowersPageState();
}
class _FollowersPageState extends ConsumerState<FollowersPage> {
List<dynamic> users = const [];
BuildContext? _overlayCtx;
Map<String, dynamic>? _pickUser(dynamic item) {
if (item is Map<String, dynamic>) {
if (item['username'] is String) return item;
if (item['follower'] is Map) {
return (item['follower'] as Map).cast<String, dynamic>();
}
if (item['user'] is Map) {
return (item['user'] as Map).cast<String, dynamic>();
}
if (item['followee'] is Map) {
return (item['followee'] as Map).cast<String, dynamic>();
}
}
return null;
}
Future<void> _load([BuildContext? overlayCtx]) async {
final client = ref.read(httpClientProvider);
if (client == null) return;
final selfId = ref.read(authStateProvider).selfUserId;
if (selfId == null) return;
final messenger = ScaffoldMessenger.of(context);
final ctx = overlayCtx ?? _overlayCtx;
if (ctx == null) return;
final overlay = ctx.loaderOverlay;
overlay.show();
try {
final res = await client.send<List<dynamic>>(
'/users/followers',
body: {'userId': selfId, 'limit': 30, 'detailed': true},
options: const RequestOptions(idempotent: true),
);
if (!mounted) return;
setState(() => users = res);
} on MisskeyApiException catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Followers error: ${e.message}')));
} finally {
if (mounted && _overlayCtx == ctx) overlay.hide();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _overlayCtx != null) _load(_overlayCtx);
});
}
@override
Widget build(BuildContext context) {
return LoaderOverlay(
child: Builder(
builder: (c) {
_overlayCtx = c;
return RefreshIndicator(
onRefresh: () => _load(c),
child: ListView.builder(
itemCount: users.length,
itemBuilder: (c, i) => ListTile(
title: Text(_pickUser(users[i])?['username']?.toString() ?? ''),
subtitle: Text(_pickUser(users[i])?['host']?.toString() ?? ''),
),
),
);
},
),
);
}
}
class LogsPage extends ConsumerWidget {
const LogsPage({super.key});
Color _levelColor(BuildContext context, LogLevel level) {
final scheme = Theme.of(context).colorScheme;
switch (level) {
case LogLevel.debug:
return scheme.onSurfaceVariant;
case LogLevel.info:
return scheme.primary;
case LogLevel.warn:
return Colors.orange;
case LogLevel.error:
return scheme.error;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final logs = ref.watch(logStoreProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
ElevatedButton.icon(
onPressed: () {
ref.read(logStoreProvider.notifier).clear();
},
icon: const Icon(Icons.clear_all),
label: const Text('Clear'),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: logs.isEmpty
? null
: () async {
final text = logs
.map((e) => '[${e.time.toIso8601String()}] ${e.level.name.toUpperCase()} ${e.message}')
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied logs')));
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy'),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
reverse: true,
itemCount: logs.length,
itemBuilder: (c, i) {
final e = logs[logs.length - 1 - i];
return ListTile(
dense: true,
title: Text(e.message, style: TextStyle(color: _levelColor(context, e.level))),
subtitle: Text(e.time.toIso8601String()),
);
},
),
),
],
);
}
}