this repo has no description

feat: implement followers and follows pages; add API methods and UI for displaying followers and follows

+769 -34
+32
lib/api.dart
··· 13 13 import 'package:mime/mime.dart'; 14 14 15 15 import './auth.dart'; 16 + import 'models/followers_result.dart'; 17 + import 'models/follows_result.dart'; 16 18 import 'models/gallery.dart'; 17 19 import 'models/gallery_thread.dart'; 18 20 import 'models/notification.dart' as grain; ··· 663 665 } 664 666 appLogger.i('Profile updated successfully'); 665 667 return true; 668 + } 669 + 670 + /// Fetch followers for a given actor DID 671 + Future<FollowersResult> getFollowers({ 672 + required String actor, 673 + String? cursor, 674 + int limit = 50, 675 + }) async { 676 + final uri = Uri.parse( 677 + '$_apiUrl/xrpc/social.grain.graph.getFollowers?actor=$actor&limit=$limit${cursor != null ? '&cursor=$cursor' : ''}', 678 + ); 679 + final response = await http.get(uri, headers: {'Content-Type': 'application/json'}); 680 + if (response.statusCode != 200) { 681 + throw Exception('Failed to fetch followers: \\${response.statusCode} \\${response.body}'); 682 + } 683 + final json = jsonDecode(response.body); 684 + return FollowersResult.fromJson(json); 685 + } 686 + 687 + /// Fetch follows for a given actor DID 688 + Future<FollowsResult> getFollows({required String actor, String? cursor, int limit = 50}) async { 689 + final uri = Uri.parse( 690 + '$_apiUrl/xrpc/social.grain.graph.getFollows?actor=$actor&limit=$limit${cursor != null ? '&cursor=$cursor' : ''}', 691 + ); 692 + final response = await http.get(uri, headers: {'Content-Type': 'application/json'}); 693 + if (response.statusCode != 200) { 694 + throw Exception('Failed to fetch follows: \\${response.statusCode} \\${response.body}'); 695 + } 696 + final json = jsonDecode(response.body); 697 + return FollowsResult.fromJson(json); 666 698 } 667 699 } 668 700
+22
lib/models/followers_result.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + import 'profile.dart'; 4 + 5 + part 'followers_result.freezed.dart'; 6 + part 'followers_result.g.dart'; 7 + 8 + @freezed 9 + class FollowersResult with _$FollowersResult { 10 + const factory FollowersResult({ 11 + required dynamic subject, 12 + required List<Profile> followers, 13 + String? cursor, 14 + }) = _FollowersResult; 15 + 16 + factory FollowersResult.fromJson(Map<String, dynamic> json) => FollowersResult( 17 + subject: json['subject'], 18 + followers: 19 + (json['followers'] as List<dynamic>?)?.map((e) => Profile.fromJson(e)).toList() ?? [], 20 + cursor: json['cursor'], 21 + ); 22 + }
+228
lib/models/followers_result.freezed.dart
··· 1 + // coverage:ignore-file 2 + // GENERATED CODE - DO NOT MODIFY BY HAND 3 + // ignore_for_file: type=lint 4 + // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 + 6 + part of 'followers_result.dart'; 7 + 8 + // ************************************************************************** 9 + // FreezedGenerator 10 + // ************************************************************************** 11 + 12 + T _$identity<T>(T value) => value; 13 + 14 + final _privateConstructorUsedError = UnsupportedError( 15 + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', 16 + ); 17 + 18 + FollowersResult _$FollowersResultFromJson(Map<String, dynamic> json) { 19 + return _FollowersResult.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$FollowersResult { 24 + dynamic get subject => throw _privateConstructorUsedError; 25 + List<Profile> get followers => throw _privateConstructorUsedError; 26 + String? get cursor => throw _privateConstructorUsedError; 27 + 28 + /// Serializes this FollowersResult to a JSON map. 29 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 30 + 31 + /// Create a copy of FollowersResult 32 + /// with the given fields replaced by the non-null parameter values. 33 + @JsonKey(includeFromJson: false, includeToJson: false) 34 + $FollowersResultCopyWith<FollowersResult> get copyWith => 35 + throw _privateConstructorUsedError; 36 + } 37 + 38 + /// @nodoc 39 + abstract class $FollowersResultCopyWith<$Res> { 40 + factory $FollowersResultCopyWith( 41 + FollowersResult value, 42 + $Res Function(FollowersResult) then, 43 + ) = _$FollowersResultCopyWithImpl<$Res, FollowersResult>; 44 + @useResult 45 + $Res call({dynamic subject, List<Profile> followers, String? cursor}); 46 + } 47 + 48 + /// @nodoc 49 + class _$FollowersResultCopyWithImpl<$Res, $Val extends FollowersResult> 50 + implements $FollowersResultCopyWith<$Res> { 51 + _$FollowersResultCopyWithImpl(this._value, this._then); 52 + 53 + // ignore: unused_field 54 + final $Val _value; 55 + // ignore: unused_field 56 + final $Res Function($Val) _then; 57 + 58 + /// Create a copy of FollowersResult 59 + /// with the given fields replaced by the non-null parameter values. 60 + @pragma('vm:prefer-inline') 61 + @override 62 + $Res call({ 63 + Object? subject = freezed, 64 + Object? followers = null, 65 + Object? cursor = freezed, 66 + }) { 67 + return _then( 68 + _value.copyWith( 69 + subject: freezed == subject 70 + ? _value.subject 71 + : subject // ignore: cast_nullable_to_non_nullable 72 + as dynamic, 73 + followers: null == followers 74 + ? _value.followers 75 + : followers // ignore: cast_nullable_to_non_nullable 76 + as List<Profile>, 77 + cursor: freezed == cursor 78 + ? _value.cursor 79 + : cursor // ignore: cast_nullable_to_non_nullable 80 + as String?, 81 + ) 82 + as $Val, 83 + ); 84 + } 85 + } 86 + 87 + /// @nodoc 88 + abstract class _$$FollowersResultImplCopyWith<$Res> 89 + implements $FollowersResultCopyWith<$Res> { 90 + factory _$$FollowersResultImplCopyWith( 91 + _$FollowersResultImpl value, 92 + $Res Function(_$FollowersResultImpl) then, 93 + ) = __$$FollowersResultImplCopyWithImpl<$Res>; 94 + @override 95 + @useResult 96 + $Res call({dynamic subject, List<Profile> followers, String? cursor}); 97 + } 98 + 99 + /// @nodoc 100 + class __$$FollowersResultImplCopyWithImpl<$Res> 101 + extends _$FollowersResultCopyWithImpl<$Res, _$FollowersResultImpl> 102 + implements _$$FollowersResultImplCopyWith<$Res> { 103 + __$$FollowersResultImplCopyWithImpl( 104 + _$FollowersResultImpl _value, 105 + $Res Function(_$FollowersResultImpl) _then, 106 + ) : super(_value, _then); 107 + 108 + /// Create a copy of FollowersResult 109 + /// with the given fields replaced by the non-null parameter values. 110 + @pragma('vm:prefer-inline') 111 + @override 112 + $Res call({ 113 + Object? subject = freezed, 114 + Object? followers = null, 115 + Object? cursor = freezed, 116 + }) { 117 + return _then( 118 + _$FollowersResultImpl( 119 + subject: freezed == subject 120 + ? _value.subject 121 + : subject // ignore: cast_nullable_to_non_nullable 122 + as dynamic, 123 + followers: null == followers 124 + ? _value._followers 125 + : followers // ignore: cast_nullable_to_non_nullable 126 + as List<Profile>, 127 + cursor: freezed == cursor 128 + ? _value.cursor 129 + : cursor // ignore: cast_nullable_to_non_nullable 130 + as String?, 131 + ), 132 + ); 133 + } 134 + } 135 + 136 + /// @nodoc 137 + @JsonSerializable() 138 + class _$FollowersResultImpl implements _FollowersResult { 139 + const _$FollowersResultImpl({ 140 + required this.subject, 141 + required final List<Profile> followers, 142 + this.cursor, 143 + }) : _followers = followers; 144 + 145 + factory _$FollowersResultImpl.fromJson(Map<String, dynamic> json) => 146 + _$$FollowersResultImplFromJson(json); 147 + 148 + @override 149 + final dynamic subject; 150 + final List<Profile> _followers; 151 + @override 152 + List<Profile> get followers { 153 + if (_followers is EqualUnmodifiableListView) return _followers; 154 + // ignore: implicit_dynamic_type 155 + return EqualUnmodifiableListView(_followers); 156 + } 157 + 158 + @override 159 + final String? cursor; 160 + 161 + @override 162 + String toString() { 163 + return 'FollowersResult(subject: $subject, followers: $followers, cursor: $cursor)'; 164 + } 165 + 166 + @override 167 + bool operator ==(Object other) { 168 + return identical(this, other) || 169 + (other.runtimeType == runtimeType && 170 + other is _$FollowersResultImpl && 171 + const DeepCollectionEquality().equals(other.subject, subject) && 172 + const DeepCollectionEquality().equals( 173 + other._followers, 174 + _followers, 175 + ) && 176 + (identical(other.cursor, cursor) || other.cursor == cursor)); 177 + } 178 + 179 + @JsonKey(includeFromJson: false, includeToJson: false) 180 + @override 181 + int get hashCode => Object.hash( 182 + runtimeType, 183 + const DeepCollectionEquality().hash(subject), 184 + const DeepCollectionEquality().hash(_followers), 185 + cursor, 186 + ); 187 + 188 + /// Create a copy of FollowersResult 189 + /// with the given fields replaced by the non-null parameter values. 190 + @JsonKey(includeFromJson: false, includeToJson: false) 191 + @override 192 + @pragma('vm:prefer-inline') 193 + _$$FollowersResultImplCopyWith<_$FollowersResultImpl> get copyWith => 194 + __$$FollowersResultImplCopyWithImpl<_$FollowersResultImpl>( 195 + this, 196 + _$identity, 197 + ); 198 + 199 + @override 200 + Map<String, dynamic> toJson() { 201 + return _$$FollowersResultImplToJson(this); 202 + } 203 + } 204 + 205 + abstract class _FollowersResult implements FollowersResult { 206 + const factory _FollowersResult({ 207 + required final dynamic subject, 208 + required final List<Profile> followers, 209 + final String? cursor, 210 + }) = _$FollowersResultImpl; 211 + 212 + factory _FollowersResult.fromJson(Map<String, dynamic> json) = 213 + _$FollowersResultImpl.fromJson; 214 + 215 + @override 216 + dynamic get subject; 217 + @override 218 + List<Profile> get followers; 219 + @override 220 + String? get cursor; 221 + 222 + /// Create a copy of FollowersResult 223 + /// with the given fields replaced by the non-null parameter values. 224 + @override 225 + @JsonKey(includeFromJson: false, includeToJson: false) 226 + _$$FollowersResultImplCopyWith<_$FollowersResultImpl> get copyWith => 227 + throw _privateConstructorUsedError; 228 + }
+25
lib/models/followers_result.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'followers_result.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$FollowersResultImpl _$$FollowersResultImplFromJson( 10 + Map<String, dynamic> json, 11 + ) => _$FollowersResultImpl( 12 + subject: json['subject'], 13 + followers: (json['followers'] as List<dynamic>) 14 + .map((e) => Profile.fromJson(e as Map<String, dynamic>)) 15 + .toList(), 16 + cursor: json['cursor'] as String?, 17 + ); 18 + 19 + Map<String, dynamic> _$$FollowersResultImplToJson( 20 + _$FollowersResultImpl instance, 21 + ) => <String, dynamic>{ 22 + 'subject': instance.subject, 23 + 'followers': instance.followers, 24 + 'cursor': instance.cursor, 25 + };
+21
lib/models/follows_result.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + import 'profile.dart'; 4 + 5 + part 'follows_result.freezed.dart'; 6 + part 'follows_result.g.dart'; 7 + 8 + @freezed 9 + class FollowsResult with _$FollowsResult { 10 + const factory FollowsResult({ 11 + required dynamic subject, 12 + required List<Profile> follows, 13 + String? cursor, 14 + }) = _FollowsResult; 15 + 16 + factory FollowsResult.fromJson(Map<String, dynamic> json) => FollowsResult( 17 + subject: json['subject'], 18 + follows: (json['follows'] as List<dynamic>?)?.map((e) => Profile.fromJson(e)).toList() ?? [], 19 + cursor: json['cursor'], 20 + ); 21 + }
+222
lib/models/follows_result.freezed.dart
··· 1 + // coverage:ignore-file 2 + // GENERATED CODE - DO NOT MODIFY BY HAND 3 + // ignore_for_file: type=lint 4 + // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 + 6 + part of 'follows_result.dart'; 7 + 8 + // ************************************************************************** 9 + // FreezedGenerator 10 + // ************************************************************************** 11 + 12 + T _$identity<T>(T value) => value; 13 + 14 + final _privateConstructorUsedError = UnsupportedError( 15 + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', 16 + ); 17 + 18 + FollowsResult _$FollowsResultFromJson(Map<String, dynamic> json) { 19 + return _FollowsResult.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$FollowsResult { 24 + dynamic get subject => throw _privateConstructorUsedError; 25 + List<Profile> get follows => throw _privateConstructorUsedError; 26 + String? get cursor => throw _privateConstructorUsedError; 27 + 28 + /// Serializes this FollowsResult to a JSON map. 29 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 30 + 31 + /// Create a copy of FollowsResult 32 + /// with the given fields replaced by the non-null parameter values. 33 + @JsonKey(includeFromJson: false, includeToJson: false) 34 + $FollowsResultCopyWith<FollowsResult> get copyWith => 35 + throw _privateConstructorUsedError; 36 + } 37 + 38 + /// @nodoc 39 + abstract class $FollowsResultCopyWith<$Res> { 40 + factory $FollowsResultCopyWith( 41 + FollowsResult value, 42 + $Res Function(FollowsResult) then, 43 + ) = _$FollowsResultCopyWithImpl<$Res, FollowsResult>; 44 + @useResult 45 + $Res call({dynamic subject, List<Profile> follows, String? cursor}); 46 + } 47 + 48 + /// @nodoc 49 + class _$FollowsResultCopyWithImpl<$Res, $Val extends FollowsResult> 50 + implements $FollowsResultCopyWith<$Res> { 51 + _$FollowsResultCopyWithImpl(this._value, this._then); 52 + 53 + // ignore: unused_field 54 + final $Val _value; 55 + // ignore: unused_field 56 + final $Res Function($Val) _then; 57 + 58 + /// Create a copy of FollowsResult 59 + /// with the given fields replaced by the non-null parameter values. 60 + @pragma('vm:prefer-inline') 61 + @override 62 + $Res call({ 63 + Object? subject = freezed, 64 + Object? follows = null, 65 + Object? cursor = freezed, 66 + }) { 67 + return _then( 68 + _value.copyWith( 69 + subject: freezed == subject 70 + ? _value.subject 71 + : subject // ignore: cast_nullable_to_non_nullable 72 + as dynamic, 73 + follows: null == follows 74 + ? _value.follows 75 + : follows // ignore: cast_nullable_to_non_nullable 76 + as List<Profile>, 77 + cursor: freezed == cursor 78 + ? _value.cursor 79 + : cursor // ignore: cast_nullable_to_non_nullable 80 + as String?, 81 + ) 82 + as $Val, 83 + ); 84 + } 85 + } 86 + 87 + /// @nodoc 88 + abstract class _$$FollowsResultImplCopyWith<$Res> 89 + implements $FollowsResultCopyWith<$Res> { 90 + factory _$$FollowsResultImplCopyWith( 91 + _$FollowsResultImpl value, 92 + $Res Function(_$FollowsResultImpl) then, 93 + ) = __$$FollowsResultImplCopyWithImpl<$Res>; 94 + @override 95 + @useResult 96 + $Res call({dynamic subject, List<Profile> follows, String? cursor}); 97 + } 98 + 99 + /// @nodoc 100 + class __$$FollowsResultImplCopyWithImpl<$Res> 101 + extends _$FollowsResultCopyWithImpl<$Res, _$FollowsResultImpl> 102 + implements _$$FollowsResultImplCopyWith<$Res> { 103 + __$$FollowsResultImplCopyWithImpl( 104 + _$FollowsResultImpl _value, 105 + $Res Function(_$FollowsResultImpl) _then, 106 + ) : super(_value, _then); 107 + 108 + /// Create a copy of FollowsResult 109 + /// with the given fields replaced by the non-null parameter values. 110 + @pragma('vm:prefer-inline') 111 + @override 112 + $Res call({ 113 + Object? subject = freezed, 114 + Object? follows = null, 115 + Object? cursor = freezed, 116 + }) { 117 + return _then( 118 + _$FollowsResultImpl( 119 + subject: freezed == subject 120 + ? _value.subject 121 + : subject // ignore: cast_nullable_to_non_nullable 122 + as dynamic, 123 + follows: null == follows 124 + ? _value._follows 125 + : follows // ignore: cast_nullable_to_non_nullable 126 + as List<Profile>, 127 + cursor: freezed == cursor 128 + ? _value.cursor 129 + : cursor // ignore: cast_nullable_to_non_nullable 130 + as String?, 131 + ), 132 + ); 133 + } 134 + } 135 + 136 + /// @nodoc 137 + @JsonSerializable() 138 + class _$FollowsResultImpl implements _FollowsResult { 139 + const _$FollowsResultImpl({ 140 + required this.subject, 141 + required final List<Profile> follows, 142 + this.cursor, 143 + }) : _follows = follows; 144 + 145 + factory _$FollowsResultImpl.fromJson(Map<String, dynamic> json) => 146 + _$$FollowsResultImplFromJson(json); 147 + 148 + @override 149 + final dynamic subject; 150 + final List<Profile> _follows; 151 + @override 152 + List<Profile> get follows { 153 + if (_follows is EqualUnmodifiableListView) return _follows; 154 + // ignore: implicit_dynamic_type 155 + return EqualUnmodifiableListView(_follows); 156 + } 157 + 158 + @override 159 + final String? cursor; 160 + 161 + @override 162 + String toString() { 163 + return 'FollowsResult(subject: $subject, follows: $follows, cursor: $cursor)'; 164 + } 165 + 166 + @override 167 + bool operator ==(Object other) { 168 + return identical(this, other) || 169 + (other.runtimeType == runtimeType && 170 + other is _$FollowsResultImpl && 171 + const DeepCollectionEquality().equals(other.subject, subject) && 172 + const DeepCollectionEquality().equals(other._follows, _follows) && 173 + (identical(other.cursor, cursor) || other.cursor == cursor)); 174 + } 175 + 176 + @JsonKey(includeFromJson: false, includeToJson: false) 177 + @override 178 + int get hashCode => Object.hash( 179 + runtimeType, 180 + const DeepCollectionEquality().hash(subject), 181 + const DeepCollectionEquality().hash(_follows), 182 + cursor, 183 + ); 184 + 185 + /// Create a copy of FollowsResult 186 + /// with the given fields replaced by the non-null parameter values. 187 + @JsonKey(includeFromJson: false, includeToJson: false) 188 + @override 189 + @pragma('vm:prefer-inline') 190 + _$$FollowsResultImplCopyWith<_$FollowsResultImpl> get copyWith => 191 + __$$FollowsResultImplCopyWithImpl<_$FollowsResultImpl>(this, _$identity); 192 + 193 + @override 194 + Map<String, dynamic> toJson() { 195 + return _$$FollowsResultImplToJson(this); 196 + } 197 + } 198 + 199 + abstract class _FollowsResult implements FollowsResult { 200 + const factory _FollowsResult({ 201 + required final dynamic subject, 202 + required final List<Profile> follows, 203 + final String? cursor, 204 + }) = _$FollowsResultImpl; 205 + 206 + factory _FollowsResult.fromJson(Map<String, dynamic> json) = 207 + _$FollowsResultImpl.fromJson; 208 + 209 + @override 210 + dynamic get subject; 211 + @override 212 + List<Profile> get follows; 213 + @override 214 + String? get cursor; 215 + 216 + /// Create a copy of FollowsResult 217 + /// with the given fields replaced by the non-null parameter values. 218 + @override 219 + @JsonKey(includeFromJson: false, includeToJson: false) 220 + _$$FollowsResultImplCopyWith<_$FollowsResultImpl> get copyWith => 221 + throw _privateConstructorUsedError; 222 + }
+23
lib/models/follows_result.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'follows_result.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$FollowsResultImpl _$$FollowsResultImplFromJson(Map<String, dynamic> json) => 10 + _$FollowsResultImpl( 11 + subject: json['subject'], 12 + follows: (json['follows'] as List<dynamic>) 13 + .map((e) => Profile.fromJson(e as Map<String, dynamic>)) 14 + .toList(), 15 + cursor: json['cursor'] as String?, 16 + ); 17 + 18 + Map<String, dynamic> _$$FollowsResultImplToJson(_$FollowsResultImpl instance) => 19 + <String, dynamic>{ 20 + 'subject': instance.subject, 21 + 'follows': instance.follows, 22 + 'cursor': instance.cursor, 23 + };
+1 -5
lib/screens/comments_page.dart
··· 59 59 appBar: AppBar( 60 60 backgroundColor: theme.appBarTheme.backgroundColor, 61 61 surfaceTintColor: theme.appBarTheme.backgroundColor, 62 - bottom: PreferredSize( 63 - preferredSize: const Size.fromHeight(1), 64 - child: Container(color: theme.dividerColor, height: 1), 65 - ), 66 - title: Text('Comments', style: theme.appBarTheme.titleTextStyle), 62 + title: Text('Comments'), 67 63 ), 68 64 body: GestureDetector( 69 65 behavior: HitTestBehavior.translucent,
+79
lib/screens/followers_page.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:grain/api.dart'; 3 + import 'package:grain/models/profile.dart'; 4 + import 'package:grain/screens/profile_page.dart'; 5 + 6 + class FollowersPage extends StatefulWidget { 7 + final String actorDid; 8 + const FollowersPage({super.key, required this.actorDid}); 9 + 10 + @override 11 + State<FollowersPage> createState() => _FollowersPageState(); 12 + } 13 + 14 + class _FollowersPageState extends State<FollowersPage> { 15 + List<Profile> followers = []; 16 + bool loading = true; 17 + String? cursor; 18 + bool hasMore = true; 19 + 20 + @override 21 + void initState() { 22 + super.initState(); 23 + fetchFollowers(); 24 + } 25 + 26 + Future<void> fetchFollowers() async { 27 + if (!hasMore) return; 28 + setState(() => loading = true); 29 + final result = await apiService.getFollowers(actor: widget.actorDid, cursor: cursor); 30 + setState(() { 31 + followers.addAll(result.followers); 32 + cursor = result.cursor; 33 + hasMore = result.cursor != null; 34 + loading = false; 35 + }); 36 + } 37 + 38 + @override 39 + Widget build(BuildContext context) { 40 + final theme = Theme.of(context); 41 + return Scaffold( 42 + appBar: AppBar( 43 + title: const Text('Followers'), 44 + backgroundColor: theme.appBarTheme.backgroundColor, 45 + surfaceTintColor: theme.appBarTheme.backgroundColor, 46 + ), 47 + body: ListView.builder( 48 + itemCount: followers.length + (hasMore ? 1 : 0), 49 + itemBuilder: (context, index) { 50 + if (index < followers.length) { 51 + final user = followers[index]; 52 + return ListTile( 53 + leading: user.avatar != null 54 + ? CircleAvatar( 55 + backgroundColor: theme.colorScheme.surfaceContainerHighest, 56 + backgroundImage: NetworkImage(user.avatar!), 57 + ) 58 + : null, 59 + title: Text(user.displayName ?? user.handle), 60 + subtitle: Text('@${user.handle}'), 61 + onTap: () { 62 + Navigator.of(context).push( 63 + MaterialPageRoute(builder: (_) => ProfilePage(did: user.did, showAppBar: true)), 64 + ); 65 + }, 66 + ); 67 + } else { 68 + WidgetsBinding.instance.addPostFrameCallback((_) { 69 + if (!loading && hasMore) fetchFollowers(); 70 + }); 71 + return const Center( 72 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 73 + ); 74 + } 75 + }, 76 + ), 77 + ); 78 + } 79 + }
+79
lib/screens/follows_page.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:grain/api.dart'; 3 + import 'package:grain/models/profile.dart'; 4 + import 'package:grain/screens/profile_page.dart'; 5 + 6 + class FollowsPage extends StatefulWidget { 7 + final String actorDid; 8 + const FollowsPage({super.key, required this.actorDid}); 9 + 10 + @override 11 + State<FollowsPage> createState() => _FollowsPageState(); 12 + } 13 + 14 + class _FollowsPageState extends State<FollowsPage> { 15 + List<Profile> follows = []; 16 + bool loading = true; 17 + String? cursor; 18 + bool hasMore = true; 19 + 20 + @override 21 + void initState() { 22 + super.initState(); 23 + fetchFollows(); 24 + } 25 + 26 + Future<void> fetchFollows() async { 27 + if (!hasMore) return; 28 + setState(() => loading = true); 29 + final result = await apiService.getFollows(actor: widget.actorDid, cursor: cursor); 30 + setState(() { 31 + follows.addAll(result.follows); 32 + cursor = result.cursor; 33 + hasMore = result.cursor != null; 34 + loading = false; 35 + }); 36 + } 37 + 38 + @override 39 + Widget build(BuildContext context) { 40 + final theme = Theme.of(context); 41 + return Scaffold( 42 + appBar: AppBar( 43 + title: const Text('Following'), 44 + backgroundColor: theme.appBarTheme.backgroundColor, 45 + surfaceTintColor: theme.appBarTheme.backgroundColor, 46 + ), 47 + body: ListView.builder( 48 + itemCount: follows.length + (hasMore ? 1 : 0), 49 + itemBuilder: (context, index) { 50 + if (index < follows.length) { 51 + final user = follows[index]; 52 + return ListTile( 53 + leading: user.avatar != null 54 + ? CircleAvatar( 55 + backgroundColor: theme.colorScheme.surfaceContainerHighest, 56 + backgroundImage: NetworkImage(user.avatar!), 57 + ) 58 + : null, 59 + title: Text(user.displayName ?? user.handle), 60 + subtitle: Text('@${user.handle}'), 61 + onTap: () { 62 + Navigator.of(context).push( 63 + MaterialPageRoute(builder: (_) => ProfilePage(did: user.did, showAppBar: true)), 64 + ); 65 + }, 66 + ); 67 + } else { 68 + WidgetsBinding.instance.addPostFrameCallback((_) { 69 + if (!loading && hasMore) fetchFollows(); 70 + }); 71 + return const Center( 72 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 73 + ); 74 + } 75 + }, 76 + ), 77 + ); 78 + } 79 + }
+1 -8
lib/screens/gallery_page.dart
··· 98 98 appBar: AppBar( 99 99 backgroundColor: theme.appBarTheme.backgroundColor, 100 100 surfaceTintColor: theme.appBarTheme.backgroundColor, 101 - bottom: PreferredSize( 102 - preferredSize: const Size.fromHeight(1), 103 - child: Container(color: theme.dividerColor, height: 1), 104 - ), 105 - title: Text( 106 - 'Gallery', 107 - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), 108 - ), 101 + title: Text('Gallery'), 109 102 iconTheme: theme.appBarTheme.iconTheme, 110 103 titleTextStyle: theme.appBarTheme.titleTextStyle, 111 104 actions: [
+1 -5
lib/screens/hashtag_page.dart
··· 40 40 final theme = Theme.of(context); 41 41 return Scaffold( 42 42 appBar: AppBar( 43 - bottom: PreferredSize( 44 - preferredSize: const Size.fromHeight(1), 45 - child: Container(color: theme.dividerColor, height: 1), 46 - ), 47 43 backgroundColor: theme.appBarTheme.backgroundColor, 48 44 surfaceTintColor: theme.appBarTheme.backgroundColor, 49 45 elevation: 0.5, 50 - title: Text('#${widget.hashtag}', style: theme.appBarTheme.titleTextStyle), 46 + title: Text('#${widget.hashtag}'), 51 47 ), 52 48 body: _loading 53 49 ? Center(
+1 -1
lib/screens/home_page.dart
··· 315 315 snap: false, 316 316 pinned: true, 317 317 elevation: 0.5, 318 - title: Text(widget.title, style: theme.appBarTheme.titleTextStyle), 318 + title: Text(widget.title), 319 319 leading: Builder( 320 320 builder: (context) => IconButton( 321 321 icon: const Icon(Icons.menu),
+1 -5
lib/screens/log_page.dart
··· 11 11 12 12 return Scaffold( 13 13 appBar: AppBar( 14 - title: Text('Logs', style: theme.appBarTheme.titleTextStyle), 14 + title: Text('Logs'), 15 15 backgroundColor: theme.appBarTheme.backgroundColor, 16 - bottom: PreferredSize( 17 - preferredSize: const Size.fromHeight(1), 18 - child: Container(color: theme.dividerColor, height: 1), 19 - ), 20 16 actions: [ 21 17 IconButton( 22 18 icon: Icon(Icons.delete, color: theme.iconTheme.color),
+33 -10
lib/screens/profile_page.dart
··· 13 13 import 'package:grain/widgets/faceted_text.dart'; 14 14 import 'package:url_launcher/url_launcher.dart'; 15 15 16 + import 'followers_page.dart'; 17 + import 'follows_page.dart'; 16 18 import 'gallery_page.dart'; 17 19 18 20 class ProfilePage extends ConsumerStatefulWidget { ··· 187 189 ? AppBar( 188 190 backgroundColor: theme.appBarTheme.backgroundColor, 189 191 surfaceTintColor: theme.appBarTheme.backgroundColor, 190 - bottom: PreferredSize( 191 - preferredSize: const Size.fromHeight(1), 192 - child: Container(color: theme.dividerColor, height: 1), 193 - ), 194 192 leading: const BackButton(), 195 193 ) 196 194 : null, ··· 334 332 ) ?? 335 333 0) 336 334 .toString(), 335 + did: profile.did, 337 336 ), 338 337 if ((profile.cameras?.isNotEmpty ?? false)) ...[ 339 338 const SizedBox(height: 16), ··· 585 584 final String followers; 586 585 final String following; 587 586 final String galleries; 587 + final String did; 588 588 const _ProfileStatsRow({ 589 589 required this.followers, 590 590 required this.following, 591 591 required this.galleries, 592 + required this.did, 592 593 }); 593 594 594 595 @override ··· 605 606 return Row( 606 607 mainAxisAlignment: MainAxisAlignment.start, 607 608 children: [ 608 - Text(followers, style: styleCount), 609 - const SizedBox(width: 4), 610 - Text('followers', style: styleLabel), 609 + GestureDetector( 610 + onTap: () { 611 + Navigator.of( 612 + context, 613 + ).push(MaterialPageRoute(builder: (_) => FollowersPage(actorDid: did))); 614 + }, 615 + child: Row( 616 + children: [ 617 + Text(followers, style: styleCount), 618 + const SizedBox(width: 4), 619 + Text('followers', style: styleLabel), 620 + ], 621 + ), 622 + ), 611 623 const SizedBox(width: 16), 612 - Text(following, style: styleCount), 613 - const SizedBox(width: 4), 614 - Text('following', style: styleLabel), 624 + GestureDetector( 625 + onTap: () { 626 + Navigator.of( 627 + context, 628 + ).push(MaterialPageRoute(builder: (_) => FollowsPage(actorDid: did))); 629 + }, 630 + child: Row( 631 + children: [ 632 + Text(following, style: styleCount), 633 + const SizedBox(width: 4), 634 + Text('following', style: styleLabel), 635 + ], 636 + ), 637 + ), 615 638 const SizedBox(width: 16), 616 639 Text(galleries, style: styleCount), 617 640 const SizedBox(width: 4),