mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph;
2import 'package:flutter/material.dart';
3import 'package:flutter_animate/flutter_animate.dart';
4import 'package:flutter_bloc/flutter_bloc.dart';
5import 'package:go_router/go_router.dart';
6import 'package:lazurite/core/theme/animation_tokens.dart';
7import 'package:lazurite/core/theme/animation_utils.dart';
8import 'package:lazurite/features/auth/bloc/auth_bloc.dart';
9import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart';
10import 'package:lazurite/features/lists/data/list_repository.dart';
11import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_dialog.dart';
12import 'package:lazurite/features/lists/presentation/widgets/list_row_tile.dart';
13import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart';
14import 'package:lazurite/shared/presentation/widgets/empty_state.dart';
15import 'package:lazurite/shared/presentation/widgets/error_state.dart';
16import 'package:lazurite/shared/presentation/widgets/loading_state.dart';
17import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart';
18
19class MyListsScreen extends StatelessWidget {
20 const MyListsScreen({super.key});
21
22 @override
23 Widget build(BuildContext context) {
24 final actor = context.read<AuthBloc>().state.tokens?.did ?? '';
25 return BlocProvider(
26 create: (context) => MyListsCubit(listRepository: context.read<ListRepository>())..load(actor: actor),
27 child: const _MyListsView(),
28 );
29 }
30}
31
32class _MyListsView extends StatefulWidget {
33 const _MyListsView();
34
35 @override
36 State<_MyListsView> createState() => _MyListsViewState();
37}
38
39class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderStateMixin {
40 late final TabController _tabController;
41 final Set<String> _seenListUris = <String>{};
42
43 @override
44 void initState() {
45 super.initState();
46 _tabController = TabController(length: 2, vsync: this);
47 }
48
49 @override
50 void dispose() {
51 _tabController.dispose();
52 super.dispose();
53 }
54
55 Future<void> _showCreateDialog(BuildContext context) async {
56 final result = await showDialog<CreateEditListResult>(
57 context: context,
58 builder: (_) => const CreateEditListDialog(),
59 );
60
61 if (result == null || !context.mounted) return;
62
63 final authState = context.read<AuthBloc>().state;
64 final userDid = authState.tokens?.did;
65 if (userDid == null) return;
66
67 final listUri = await context.read<MyListsCubit>().createList(
68 userDid: userDid,
69 name: result.name,
70 purpose: result.purpose,
71 description: result.description,
72 avatarBytes: result.avatarBytes,
73 avatarMimeType: result.avatarMimeType,
74 );
75
76 if (listUri != null && context.mounted) {
77 await context.push('/list?uri=${Uri.encodeComponent(listUri.toString())}');
78 }
79 }
80
81 @override
82 Widget build(BuildContext context) {
83 return Scaffold(
84 appBar: AppBar(
85 title: const Text('My Lists'),
86 bottom: TabBar(
87 controller: _tabController,
88 tabs: const [
89 Tab(text: 'FEEDS'),
90 Tab(text: 'MODERATION'),
91 ],
92 labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2),
93 unselectedLabelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2),
94 indicatorWeight: 2,
95 ),
96 ),
97 body: BlocBuilder<MyListsCubit, MyListsState>(
98 builder: (context, state) {
99 if (state.status == MyListsStatus.loading) {
100 return const LoadingState();
101 }
102
103 if (state.status == MyListsStatus.error) {
104 return ErrorState(
105 title: 'Failed to load lists',
106 message: state.errorMessage ?? 'Unknown error',
107 onRetry: () => context.read<MyListsCubit>().refresh(),
108 );
109 }
110
111 return TabBarView(
112 controller: _tabController,
113 children: [_buildListTab(context, state.curationLists), _buildListTab(context, state.moderationLists)],
114 );
115 },
116 ),
117 floatingActionButton:
118 FloatingActionButton(
119 heroTag: 'my-lists-fab',
120 onPressed: () => _showCreateDialog(context),
121 child: const Icon(Icons.add),
122 ).animateIfAllowed(
123 context,
124 effects: const [
125 FadeEffect(duration: Anim.feedItem, curve: Anim.enter),
126 ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis),
127 ],
128 ),
129 );
130 }
131
132 Widget _buildListTab(BuildContext context, List<bsky_graph.ListView> lists) {
133 if (lists.isEmpty) {
134 return const EmptyState(message: 'No lists yet', icon: Icons.list_alt_outlined);
135 }
136
137 return AnimatedRefreshIndicator(
138 onRefresh: () => context.read<MyListsCubit>().refresh(),
139 child: ListView.builder(
140 itemCount: lists.length,
141 itemBuilder: (context, index) {
142 final list = lists[index];
143 return StaggeredEntrance(
144 itemKey: list.uri.toString(),
145 index: index,
146 seenKeys: _seenListUris,
147 child: ListRowTile(
148 key: ValueKey(list.uri),
149 list: list,
150 onTap: () => context.push('/list?uri=${Uri.encodeComponent(list.uri.toString())}'),
151 ),
152 );
153 },
154 ),
155 );
156 }
157}